Files
Melchior Reimers a07319d957
Some checks failed
Deployment / deploy-docker (push) Has been cancelled
Fix: Analytics Worker berechnet jetzt alle Tabellen pro Tag
2026-01-29 16:00:09 +01:00

999 lines
52 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trading Intelligence Hub</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="/static/analytics-config.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Outfit', sans-serif; background-color: #0b1120; color: #e2e8f0; }
.glass { background: rgba(30, 41, 59, 0.4); backdrop-filter: blur(12px); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; }
.active-nav { background: rgba(56, 189, 248, 0.1); color: #38bdf8; border-right: 3px solid #38bdf8; }
.input-glass { width: 100%; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 10px; outline: none; transition: all 0.2s; color: #e2e8f0; }
.input-glass:focus { border-color: #38bdf8; background: rgba(56, 189, 248, 0.05); }
.spinner { width: 40px; height: 40px; border: 3px solid rgba(56, 189, 248, 0.1); border-top: 3px solid #38bdf8; border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
</style>
</head>
<body class="flex min-h-screen overflow-hidden">
<aside class="w-20 lg:w-64 glass m-4 mr-0 flex flex-col hidden sm:flex">
<div class="p-6 hidden lg:block">
<h1 class="text-2xl font-bold bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">Trading Hub</h1>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4" id="sidebar">
<a href="#" onclick="showView('dashboard')" id="nav-dashboard" class="flex items-center p-3 rounded-xl transition active-nav">
<span class="mr-3">📊</span> <span class="hidden lg:inline">Dashboard</span>
</a>
<a href="#" onclick="showView('custom-analytics')" id="nav-custom-analytics" class="flex items-center p-3 rounded-xl transition">
<span class="mr-3">📈</span> <span class="hidden lg:inline">Custom Analytics</span>
</a>
</nav>
</aside>
<main class="flex-1 flex flex-col min-w-0">
<header class="p-8 pb-4 flex justify-between items-center flex-shrink-0">
<div>
<h2 class="text-3xl font-bold" id="pageTitle">Market Overview</h2>
</div>
<button onclick="fetchData()" class="glass p-2 px-6 text-sky-400 font-bold hover:text-sky-200 transition">↻ REFRESH</button>
</header>
<div id="view-dashboard" class="view flex-1 p-8 pt-4 overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="glass p-8 border-l-4 border-sky-400">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2" id="statVolumeLabel">Volume</p>
<h3 id="statVolume" class="text-4xl font-bold">€0.0</h3>
</div>
<div class="glass p-8 border-l-4 border-amber-400">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2" id="statTradesLabel">Total Trades</p>
<h3 id="statTrades" class="text-4xl font-bold">0</h3>
</div>
<div class="glass p-8 border-l-4 border-emerald-400">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2">Assets</p>
<h3 id="statIsins" class="text-4xl font-bold">0</h3>
</div>
</div>
<div class="mt-10 mb-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-slate-200">Erweiterte Statistiken</h2>
<select id="statisticsPeriod" class="input-glass px-4 py-2" onchange="loadStatistics()">
<option value="7">7 Tage</option>
<option value="30">30 Tage</option>
<option value="42">42 Tage</option>
<option value="69">69 Tage</option>
<option value="180">180 Tage</option>
<option value="365">365 Tage</option>
</select>
</div>
<div class="mb-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Moving Average: Tradezahlen & Volumen je Exchange</h3>
<p class="text-sm text-slate-500 mb-4">Blau = Trades (7-Tage MA), Grün = Volumen (7-Tage MA)</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-sky-400">EIX</h4>
<div class="h-48"><canvas id="maChartEIX"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-rose-400">LS (Lang & Schwarz)</h4>
<div class="h-48"><canvas id="maChartLS"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-emerald-400">GETTEX</h4>
<div class="h-48"><canvas id="maChartGETTEX"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-amber-400">XETRA</h4>
<div class="h-48"><canvas id="maChartXETRA"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-violet-400">Frankfurt (FRA)</h4>
<div class="h-48"><canvas id="maChartFRA"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-orange-400">Stuttgart (STU)</h4>
<div class="h-48"><canvas id="maChartSTU"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-pink-400">Quotrix</h4>
<div class="h-48"><canvas id="maChartQUOTRIX"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-cyan-400">Düsseldorf Reg. (DUSA)</h4>
<div class="h-48"><canvas id="maChartDUSA"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-teal-400">Düsseldorf Frei. (DUSB)</h4>
<div class="h-48"><canvas id="maChartDUSB"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-lime-400">Quotrix Reg. (DUSC)</h4>
<div class="h-48"><canvas id="maChartDUSC"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-green-400">Quotrix Frei. (DUSD)</h4>
<div class="h-48"><canvas id="maChartDUSD"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-indigo-400">Hamburg Reg. (HAMA)</h4>
<div class="h-48"><canvas id="maChartHAMA"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-purple-400">Hamburg Frei. (HAMB)</h4>
<div class="h-48"><canvas id="maChartHAMB"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-fuchsia-400">Hannover Reg. (HANA)</h4>
<div class="h-48"><canvas id="maChartHANA"></canvas></div>
</div>
<div class="glass p-4">
<h4 class="text-md font-bold mb-3 text-red-400">Hannover Frei. (HANB)</h4>
<div class="h-48"><canvas id="maChartHANB"></canvas></div>
</div>
</div>
</div>
<div class="glass p-8 mb-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Tradingvolumen & Anzahl Änderungen</h3>
<div class="h-96"><canvas id="volumeChangesChart"></canvas></div>
</div>
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Trendanalyse: Häufig gehandelte Aktien</h3>
<div class="h-96"><canvas id="stockTrendsChart"></canvas></div>
<div id="stockTrendsTable" class="mt-6 overflow-x-auto"></div>
</div>
</div>
</div>
<!-- CUSTOM ANALYTICS VIEW -->
<div id="view-custom-analytics" class="view hidden flex-1 p-8 pt-4 overflow-y-auto">
<div class="glass p-8 mb-6">
<h2 class="text-2xl font-bold text-slate-200 mb-6">Custom Analytics Graph</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label class="block text-sm font-bold text-slate-400 mb-2">X-Achse (Zeitraum)</label>
<div class="grid grid-cols-2 gap-2">
<input type="date" id="customDateFrom" class="input-glass" onchange="updateCustomGraph(); updateUrlParams()">
<input type="date" id="customDateTo" class="input-glass" onchange="updateCustomGraph(); updateUrlParams()">
</div>
</div>
<div>
<label class="block text-sm font-bold text-slate-400 mb-2">Y-Achse (Metrik)</label>
<select id="customYAxis" class="input-glass" onchange="updateCustomGraph(); updateUrlParams()">
<option value="volume">Volumen</option>
<option value="trade_count">Tradezahlen</option>
<option value="avg_price">Durchschnittspreis</option>
</select>
<p class="text-xs text-slate-500 mt-1" id="yAxisDescription"></p>
</div>
<div>
<label class="block text-sm font-bold text-slate-400 mb-2">Gruppierung</label>
<select id="customGroupBy" class="input-glass" onchange="updateCustomGraph(); updateUrlParams()">
<option value="exchange">Exchange</option>
<option value="isin">ISIN</option>
<option value="date">Datum</option>
</select>
<p class="text-xs text-slate-500 mt-1" id="groupByDescription"></p>
</div>
<div>
<label class="block text-sm font-bold text-slate-400 mb-2">Exchanges (optional, komma-separiert)</label>
<input type="text" id="customExchanges" class="input-glass" placeholder="z.B. EIX,LS,XETRA,FRA,GETTEX,STU,QUOTRIX,DUSA,HAMA,HANA" onchange="updateCustomGraph(); updateUrlParams()">
</div>
</div>
<button onclick="updateCustomGraph()" class="glass px-6 py-3 bg-sky-500 hover:bg-sky-600 text-white font-bold rounded-lg transition-colors cursor-pointer">Graph aktualisieren</button>
<button onclick="shareCustomGraph()" class="glass px-6 py-3 ml-2 text-sky-400 hover:text-sky-200 font-bold rounded-lg transition-colors cursor-pointer">🔗 Link teilen</button>
</div>
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300" id="customGraphTitle">Custom Analytics Graph</h3>
<div class="h-96"><canvas id="customAnalyticsChart"></canvas></div>
</div>
</div>
</main>
<script>
const API = '/api';
let store = { trades: [], metadata: [], summary: [], totalTrades: 0 };
let charts = {};
let isRefreshing = false;
let refreshInterval = null;
async function fetchData(skipCharts = false) {
if (isRefreshing) return; // Verhindere parallele Aufrufe
isRefreshing = true;
try {
const days = parseInt(document.getElementById('statisticsPeriod')?.value || '7');
// Lade alle Trades (aggregiert) und Total Trades aus vorberechneten Daten
const [t, m, s, totalTrades] = await Promise.all([
fetch(`${API}/trades?days=${days}`).then(r => r.json()),
fetch(`${API}/metadata`).then(r => r.json()),
fetch(`${API}/summary?days=${days}`).then(r => r.json()),
fetch(`${API}/statistics/total-trades?days=${days}`).then(r => r.json())
]);
store = { ...store, trades: t.dataset || [], metadata: m.dataset || [], summary: s.dataset || [] };
store.totalTrades = totalTrades.total_trades || 0;
updateDashboard(days, skipCharts);
} catch (err) {
console.error('Error fetching data:', err);
} finally {
isRefreshing = false;
}
}
function updateDashboard(days = 7, skipCharts = false) {
// Volumen aus dem gewählten Zeitraum (aggregiert)
let vol = 0;
if (store.trades && store.trades.length > 0) {
// Trades sind jetzt aggregiert: [date, exchange, trade_count, volume]
vol = store.trades.reduce((acc, r) => acc + (parseFloat(r[3] || 0)), 0);
}
document.getElementById('statVolume').innerText = vol >= 1e6 ? `${(vol / 1e6).toFixed(1)}M` : `${(vol / 1e3).toFixed(0)}k`;
document.getElementById('statVolumeLabel').innerText = `Volume (${days}d)`;
// Total Trades aus vorberechneten Daten für den gewählten Zeitraum
document.getElementById('statTrades').innerText = (store.totalTrades || 0).toLocaleString();
document.getElementById('statTradesLabel').innerText = `Total Trades (${days}d)`;
document.getElementById('statIsins').innerText = store.metadata.length.toLocaleString();
// Lade Statistiken nur wenn nicht übersprungen (verhindert Graph-Zucken)
if (!skipCharts) {
loadStatistics();
}
}
function showView(viewId) {
if (!viewId) viewId = 'dashboard'; // Default zu dashboard
window.activeView = viewId;
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
const targetView = document.getElementById(`view-${viewId}`);
if (targetView) {
targetView.classList.remove('hidden');
}
document.querySelectorAll('#sidebar a').forEach(a => a.classList.remove('active-nav'));
const targetNav = document.getElementById(`nav-${viewId}`);
if (targetNav) {
targetNav.classList.add('active-nav');
}
const titles = { 'dashboard': 'Market Overview', 'custom-analytics': 'Custom Analytics' };
const pageTitle = document.getElementById('pageTitle');
if (pageTitle) {
pageTitle.innerText = titles[viewId] || 'Dashboard';
}
updateUrlParams();
}
async function loadStatistics(forceReload = false) {
const days = parseInt(document.getElementById('statisticsPeriod')?.value || '7');
// Beim ersten Laden oder bei expliziter Anforderung: Daten laden
if (forceReload || !store.trades || store.trades.length === 0) {
await fetchData(true); // skipCharts = true, damit Graphen nicht doppelt geladen werden
}
// Lade die Graphen
try {
await Promise.all([
loadMovingAverage(days),
loadVolumeChanges(days),
loadStockTrends(days)
]);
} catch (err) {
console.error('Error loading statistics:', err);
}
updateUrlParams();
}
async function loadMovingAverage(days) {
try {
const res = await fetch(`${API}/statistics/moving-average?days=${days}`).then(r => r.json());
const data = res.dataset || [];
const columns = res.columns || [];
console.log('Moving Average API Response:', {
dataLength: data.length,
columns: columns.map(c => c.name),
firstRow: data[0]
});
if (!data.length) {
console.warn('No moving average data available - analytics_exchange_daily table may be empty');
// Zeige leere Graphen mit "Keine Daten" Meldung
const exchangeGroups = {
'EIX': { canvasId: 'maChartEIX' },
'LS': { canvasId: 'maChartLS' },
'GETTEX': { canvasId: 'maChartGETTEX' },
'XETRA': { canvasId: 'maChartXETRA' },
'FRA': { canvasId: 'maChartFRA' },
'STU': { canvasId: 'maChartSTU' },
'QUOTRIX': { canvasId: 'maChartQUOTRIX' },
'DUSA': { canvasId: 'maChartDUSA' },
'DUSB': { canvasId: 'maChartDUSB' },
'DUSC': { canvasId: 'maChartDUSC' },
'DUSD': { canvasId: 'maChartDUSD' },
'HAMA': { canvasId: 'maChartHAMA' },
'HAMB': { canvasId: 'maChartHAMB' },
'HANA': { canvasId: 'maChartHANA' },
'HANB': { canvasId: 'maChartHANB' }
};
Object.values(exchangeGroups).forEach(config => {
const canvas = document.getElementById(config.canvasId);
if (canvas) {
const ctx = canvas.getContext('2d');
if (charts[config.canvasId]) charts[config.canvasId].destroy();
charts[config.canvasId] = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
title: { display: true, text: 'Keine Daten verfügbar' }
}
}
});
}
});
return;
}
const dateIdx = columns.findIndex(c => c.name === 'date' || c.name === 'timestamp');
const exchangeIdx = columns.findIndex(c => c.name === 'exchange');
const countIdx = columns.findIndex(c => c.name === 'trade_count');
const volumeIdx = columns.findIndex(c => c.name === 'volume');
if (dateIdx === -1 || exchangeIdx === -1 || countIdx === -1 || volumeIdx === -1) {
console.error('Missing required columns:', { dateIdx, exchangeIdx, countIdx, volumeIdx, columns });
return;
}
// Alle Exchanges mit Canvas-IDs definieren
const exchangeGroups = {
'EIX': { exchanges: ['EIX'], canvasId: 'maChartEIX' },
'LS': { exchanges: ['LS'], canvasId: 'maChartLS' },
'GETTEX': { exchanges: ['GETTEX'], canvasId: 'maChartGETTEX' },
'XETRA': { exchanges: ['XETRA'], canvasId: 'maChartXETRA' },
'FRA': { exchanges: ['FRA'], canvasId: 'maChartFRA' },
'STU': { exchanges: ['STU'], canvasId: 'maChartSTU' },
'QUOTRIX': { exchanges: ['QUOTRIX'], canvasId: 'maChartQUOTRIX' },
'DUSA': { exchanges: ['DUSA'], canvasId: 'maChartDUSA' },
'DUSB': { exchanges: ['DUSB'], canvasId: 'maChartDUSB' },
'DUSC': { exchanges: ['DUSC'], canvasId: 'maChartDUSC' },
'DUSD': { exchanges: ['DUSD'], canvasId: 'maChartDUSD' },
'HAMA': { exchanges: ['HAMA'], canvasId: 'maChartHAMA' },
'HAMB': { exchanges: ['HAMB'], canvasId: 'maChartHAMB' },
'HANA': { exchanges: ['HANA'], canvasId: 'maChartHANA' },
'HANB': { exchanges: ['HANB'], canvasId: 'maChartHANB' }
};
// Alle Daten nach Datum sortieren
const dates = [...new Set(data.map(r => {
const dateVal = r[dateIdx];
// Handle timestamp format (could be string or number)
if (typeof dateVal === 'string') {
return dateVal.split('T')[0]; // Extract date part
} else if (typeof dateVal === 'number') {
return new Date(dateVal / 1000).toISOString().split('T')[0];
}
return dateVal;
}))].sort();
console.log('Processed dates:', dates.slice(0, 5), '...', dates.slice(-5));
// Moving Average berechnen (7-Tage gleitender Durchschnitt)
const maWindow = 7;
const calculateMA = (dataArray, window) => {
return dataArray.map((val, idx) => {
if (idx < window - 1) return null;
const slice = dataArray.slice(idx - window + 1, idx + 1);
return slice.reduce((a, b) => a + b, 0) / window;
});
};
// Für jede Exchange-Gruppe einen separaten Chart erstellen
Object.entries(exchangeGroups).forEach(([groupName, config]) => {
const canvas = document.getElementById(config.canvasId);
if (!canvas) {
console.error(`Canvas element ${config.canvasId} not found`);
return;
}
// Zerstöre existierenden Chart
if (charts[config.canvasId]) charts[config.canvasId].destroy();
const ctx = canvas.getContext('2d');
// Aggregiere Daten für diese Gruppe
const groupData = {};
dates.forEach(date => {
const dayRows = data.filter(r => {
const exchange = r[exchangeIdx];
const rowDate = r[dateIdx];
let rowDateStr = rowDate;
if (typeof rowDate === 'string') {
rowDateStr = rowDate.split('T')[0];
} else if (typeof rowDate === 'number') {
rowDateStr = new Date(rowDate / 1000).toISOString().split('T')[0];
}
return rowDateStr === date && config.exchanges.includes(exchange);
});
groupData[date] = {
tradeCount: dayRows.reduce((sum, r) => sum + (parseFloat(r[countIdx]) || 0), 0),
volume: dayRows.reduce((sum, r) => sum + (parseFloat(r[volumeIdx]) || 0), 0)
};
});
const tradeData = dates.map(d => groupData[d]?.tradeCount || 0);
const volumeData = dates.map(d => groupData[d]?.volume || 0);
console.log(`${groupName} data:`, {
dates: dates.length,
tradeData: tradeData.slice(0, 5),
volumeData: volumeData.slice(0, 5)
});
const maTradeData = calculateMA(tradeData, maWindow);
const maVolumeData = calculateMA(volumeData, maWindow);
charts[config.canvasId] = new Chart(ctx, {
type: 'line',
data: {
labels: dates.map(d => new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })),
datasets: [
{
label: 'Trades (MA)',
data: maTradeData,
borderColor: '#38bdf8',
backgroundColor: '#38bdf822',
borderWidth: 2,
fill: true,
yAxisID: 'y',
tension: 0.4,
pointRadius: 0
},
{
label: 'Volumen (MA)',
data: maVolumeData,
borderColor: '#10b981',
backgroundColor: '#10b98122',
borderWidth: 2,
fill: true,
yAxisID: 'y1',
tension: 0.4,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
title: { display: false },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#64748b',
font: { size: 10 },
callback: function(value) {
if (value >= 1e6) return (value / 1e6).toFixed(0) + 'M';
if (value >= 1e3) return (value / 1e3).toFixed(0) + 'k';
return value;
}
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: false },
grid: { drawOnChartArea: false },
ticks: {
color: '#64748b',
font: { size: 10 },
callback: function(value) {
if (value >= 1e6) return '€' + (value / 1e6).toFixed(0) + 'M';
if (value >= 1e3) return '€' + (value / 1e3).toFixed(0) + 'k';
return '€' + value;
}
}
},
x: {
grid: { display: false },
ticks: {
color: '#64748b',
maxRotation: 0,
font: { size: 9 },
maxTicksLimit: 8
}
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#94a3b8', boxWidth: 10, usePointStyle: true, padding: 8, font: { size: 10 } }
},
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
if (label) label += ': ';
if (context.parsed.y !== null) {
if (context.dataset.yAxisID === 'y1') {
label += '€' + context.parsed.y.toLocaleString();
} else {
label += context.parsed.y.toLocaleString();
}
}
return label;
}
}
}
}
}
});
});
} catch (err) {
console.error('Error loading moving average:', err);
}
}
async function loadVolumeChanges(days) {
try {
const res = await fetch(`${API}/statistics/volume-changes?days=${days}`).then(r => r.json());
const data = res.dataset || [];
const columns = res.columns || [];
if (!data.length) {
console.log('No volume changes data available');
return;
}
const canvas = document.getElementById('volumeChangesChart');
if (!canvas) {
console.error('Canvas element volumeChangesChart not found');
return;
}
const ctx = canvas.getContext('2d');
if (charts.volumeChanges) charts.volumeChanges.destroy();
const exchangeIdx = columns.findIndex(c => c.name === 'exchange');
const countChangeIdx = columns.findIndex(c => c.name === 'count_change_pct');
const volumeChangeIdx = columns.findIndex(c => c.name === 'volume_change_pct');
const trendIdx = columns.findIndex(c => c.name === 'trend');
const exchanges = data.map(r => r[exchangeIdx]);
const countChanges = data.map(r => r[countChangeIdx] || 0);
const volumeChanges = data.map(r => r[volumeChangeIdx] || 0);
charts.volumeChanges = new Chart(ctx, {
type: 'bar',
data: {
labels: exchanges,
datasets: [
{
label: 'Anzahl Änderung (%)',
data: countChanges,
backgroundColor: '#38bdf866',
borderColor: '#38bdf8',
borderWidth: 2,
yAxisID: 'y'
},
{
label: 'Volumen Änderung (%)',
data: volumeChanges,
backgroundColor: '#fbbf2466',
borderColor: '#fbbf24',
borderWidth: 2,
yAxisID: 'y'
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
display: true,
title: { display: true, text: 'Änderung (%)', color: '#94a3b8' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' }
},
x: {
grid: { display: false },
ticks: { color: '#64748b' }
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 15 }
},
tooltip: {
callbacks: {
afterLabel: (context) => {
const idx = context.dataIndex;
const trend = data[idx][trendIdx];
return `Trend: ${trend || 'N/A'}`;
}
}
}
}
}
});
} catch (err) {
console.error('Error loading volume changes:', err);
}
}
async function loadStockTrends(days) {
try {
const res = await fetch(`${API}/statistics/stock-trends?days=${days}&limit=20`).then(r => r.json());
const data = res.dataset || [];
const columns = res.columns || [];
if (!data.length) {
console.log('No stock trends data available');
return;
}
const canvas = document.getElementById('stockTrendsChart');
if (!canvas) {
console.error('Canvas element stockTrendsChart not found');
return;
}
const ctx = canvas.getContext('2d');
const tableContainer = document.getElementById('stockTrendsTable');
if (charts.stockTrends) charts.stockTrends.destroy();
const isinIdx = columns.findIndex(c => c.name === 'isin');
const volumeIdx = columns.findIndex(c => c.name === 'volume');
const countIdx = columns.findIndex(c => c.name === 'trade_count');
const countChangeIdx = columns.findIndex(c => c.name === 'count_change_pct');
const volumeChangeIdx = columns.findIndex(c => c.name === 'volume_change_pct');
const sorted = [...data].sort((a, b) => (b[volumeIdx] || 0) - (a[volumeIdx] || 0)).slice(0, 10);
const isins = sorted.map(r => r[isinIdx]);
const volumes = sorted.map(r => r[volumeIdx] || 0);
charts.stockTrends = new Chart(ctx, {
type: 'bar',
data: {
labels: isins.map(i => i ? i.substring(0, 12) + '...' : 'N/A'),
datasets: [{
label: 'Volumen (€)',
data: volumes,
backgroundColor: '#10b98166',
borderColor: '#10b981',
borderWidth: 2,
yAxisID: 'y'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
type: 'linear',
display: true,
title: { display: true, text: 'Volumen (€)', color: '#94a3b8' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' }
},
x: {
grid: { display: false },
ticks: { color: '#64748b', maxRotation: 45, minRotation: 45 }
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 15 }
}
}
}
});
tableContainer.innerHTML = `
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-white/10">
<th class="p-3 text-slate-400 font-bold">ISIN</th>
<th class="p-3 text-slate-400 font-bold">Trades</th>
<th class="p-3 text-slate-400 font-bold">Volumen (€)</th>
<th class="p-3 text-slate-400 font-bold">Anzahl Δ (%)</th>
<th class="p-3 text-slate-400 font-bold">Volumen Δ (%)</th>
</tr>
</thead>
<tbody>
${sorted.map(r => `
<tr class="border-b border-white/5 hover:bg-white/5">
<td class="p-3 font-mono text-sky-400">${r[isinIdx] || 'N/A'}</td>
<td class="p-3 text-slate-300">${(r[countIdx] || 0).toLocaleString()}</td>
<td class="p-3 text-slate-300">€${((r[volumeIdx] || 0) / 1e6).toFixed(2)}M</td>
<td class="p-3 ${(r[countChangeIdx] || 0) >= 0 ? 'text-green-400' : 'text-red-400'}">${((r[countChangeIdx] || 0)).toFixed(2)}%</td>
<td class="p-3 ${(r[volumeChangeIdx] || 0) >= 0 ? 'text-green-400' : 'text-red-400'}">${((r[volumeChangeIdx] || 0)).toFixed(2)}%</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (err) {
console.error('Error loading stock trends:', err);
}
}
// Custom Analytics Funktionen
async function updateCustomGraph() {
const dateFrom = document.getElementById('customDateFrom').value;
const dateTo = document.getElementById('customDateTo').value;
const yAxis = document.getElementById('customYAxis').value;
const groupBy = document.getElementById('customGroupBy').value;
const exchanges = document.getElementById('customExchanges').value;
if (!dateFrom || !dateTo) {
console.log('Bitte wählen Sie Start- und Enddatum');
return;
}
try {
let url = `${API}/custom-analytics?date_from=${dateFrom}&date_to=${dateTo}&x_axis=date&y_axis=${yAxis}&group_by=${groupBy}`;
if (exchanges) {
url += `&exchanges=${encodeURIComponent(exchanges)}`;
}
const res = await fetch(url).then(r => r.json());
const data = res.dataset || [];
const columns = res.columns || [];
if (!data.length) {
console.log('Keine Daten für den gewählten Zeitraum');
return;
}
const ctx = document.getElementById('customAnalyticsChart').getContext('2d');
if (charts.customAnalytics) charts.customAnalytics.destroy();
const xIdx = columns.findIndex(c => c.name === 'x_value');
const groupIdx = columns.findIndex(c => c.name === 'group_value');
const yIdx = columns.findIndex(c => c.name === 'y_value');
const groups = [...new Set(data.map(r => r[groupIdx]).filter(g => g && g !== '' && g !== 'NONE'))];
const dates = [...new Set(data.map(r => r[xIdx]))].sort();
if (groups.length === 0) {
console.log('No valid groups found in data');
return;
}
// Erweiterte Farben für mehr Exchanges (EIX, LS, XETRA, FRA, GETTEX, STU, QUOTRIX)
const colors = ['#38bdf8', '#f43f5e', '#10b981', '#fbbf24', '#8b5cf6', '#f97316', '#ec4899', '#14b8a6', '#84cc16', '#a855f7'];
const datasets = groups.map((group, idx) => ({
label: group || 'Unknown',
data: dates.map(d => {
const row = data.find(r => r[xIdx] === d && r[groupIdx] === group);
return row ? (row[yIdx] || 0) : 0;
}),
borderColor: colors[idx % colors.length],
backgroundColor: colors[idx % colors.length] + '33',
borderWidth: 2,
tension: 0.3,
fill: false
}));
// Nutze Config für Y-Achsen-Label (falls verfügbar)
let yAxisLabel = 'Wert';
if (typeof ANALYTICS_CONFIG !== 'undefined' && ANALYTICS_CONFIG.yAxis && ANALYTICS_CONFIG.yAxis[yAxis]) {
const config = ANALYTICS_CONFIG.yAxis[yAxis];
yAxisLabel = `${config.label}${config.unit ? ' (' + config.unit + ')' : ''}`;
} else {
// Fallback
const labels = {
'volume': 'Volumen (€)',
'trade_count': 'Anzahl Trades',
'avg_price': 'Durchschnittspreis (€)'
};
yAxisLabel = labels[yAxis] || 'Wert';
}
charts.customAnalytics = new Chart(ctx, {
type: 'line',
data: {
labels: dates.map(d => new Date(d).toLocaleDateString()),
datasets: datasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
y: {
type: 'linear',
display: true,
title: { display: true, text: yAxisLabel, color: '#94a3b8' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' }
},
x: {
title: { display: true, text: 'Datum', color: '#94a3b8' },
grid: { display: false },
ticks: { color: '#64748b' }
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 15 }
},
tooltip: {
backgroundColor: '#1e293b',
titleColor: '#38bdf8',
bodyColor: '#e2e8f0',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1
}
}
}
});
document.getElementById('customGraphTitle').innerText =
`${yAxisLabel} nach ${groupBy === 'exchange' ? 'Exchange' : groupBy === 'isin' ? 'ISIN' : 'Datum'} (${dateFrom} bis ${dateTo})`;
} catch (err) {
console.error('Error loading custom analytics:', err);
}
}
function shareCustomGraph() {
const params = new URLSearchParams();
params.set('view', 'custom-analytics');
params.set('date_from', document.getElementById('customDateFrom').value);
params.set('date_to', document.getElementById('customDateTo').value);
params.set('y_axis', document.getElementById('customYAxis').value);
params.set('group_by', document.getElementById('customGroupBy').value);
const exchanges = document.getElementById('customExchanges').value;
if (exchanges) params.set('exchanges', exchanges);
const url = window.location.origin + window.location.pathname + '?' + params.toString();
navigator.clipboard.writeText(url).then(() => {
alert('Link in Zwischenablage kopiert!');
}).catch(() => {
prompt('Link zum Teilen:', url);
});
}
function updateUrlParams() {
const params = new URLSearchParams(window.location.search);
const view = window.activeView;
if (view) params.set('view', view);
if (view === 'custom-analytics') {
params.set('date_from', document.getElementById('customDateFrom').value || '');
params.set('date_to', document.getElementById('customDateTo').value || '');
params.set('y_axis', document.getElementById('customYAxis').value || '');
params.set('group_by', document.getElementById('customGroupBy').value || '');
const exchanges = document.getElementById('customExchanges').value;
if (exchanges) params.set('exchanges', exchanges);
else params.delete('exchanges');
} else if (view === 'dashboard') {
const period = document.getElementById('statisticsPeriod')?.value;
if (period) params.set('period', period);
}
window.history.replaceState({}, '', window.location.pathname + '?' + params.toString());
}
function loadFromUrlParams() {
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
if (view) {
showView(view);
if (view === 'custom-analytics') {
const dateFrom = params.get('date_from');
const dateTo = params.get('date_to');
const yAxis = params.get('y_axis');
const groupBy = params.get('group_by');
const exchanges = params.get('exchanges');
if (dateFrom) document.getElementById('customDateFrom').value = dateFrom;
if (dateTo) document.getElementById('customDateTo').value = dateTo;
if (yAxis) document.getElementById('customYAxis').value = yAxis;
if (groupBy) document.getElementById('customGroupBy').value = groupBy;
if (exchanges) document.getElementById('customExchanges').value = exchanges;
if (dateFrom && dateTo) {
setTimeout(() => updateCustomGraph(), 500);
}
} else if (view === 'dashboard') {
const period = params.get('period');
if (period) {
document.getElementById('statisticsPeriod').value = period;
setTimeout(() => loadStatistics(), 500);
}
}
}
}
// Initialisiere Custom Analytics Dropdowns aus Config (falls verfügbar)
function initCustomAnalyticsDropdowns() {
if (typeof ANALYTICS_CONFIG !== 'undefined') {
// Y-Achse Optionen
const yAxisSelect = document.getElementById('customYAxis');
if (yAxisSelect && ANALYTICS_CONFIG.yAxis) {
yAxisSelect.innerHTML = '';
Object.entries(ANALYTICS_CONFIG.yAxis).forEach(([key, config]) => {
const option = document.createElement('option');
option.value = key;
option.textContent = config.label;
yAxisSelect.appendChild(option);
});
}
// GroupBy Optionen
const groupBySelect = document.getElementById('customGroupBy');
if (groupBySelect && ANALYTICS_CONFIG.groupBy) {
groupBySelect.innerHTML = '';
Object.entries(ANALYTICS_CONFIG.groupBy).forEach(([key, config]) => {
const option = document.createElement('option');
option.value = key;
option.textContent = config.label;
groupBySelect.appendChild(option);
});
}
}
}
window.onload = async () => {
// Initialisiere Dropdowns aus Config
initCustomAnalyticsDropdowns();
// Setze Standard-Datum (letzte 30 Tage)
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
if (!document.getElementById('customDateTo').value) {
document.getElementById('customDateTo').value = today.toISOString().split('T')[0];
}
if (!document.getElementById('customDateFrom').value) {
document.getElementById('customDateFrom').value = thirtyDaysAgo.toISOString().split('T')[0];
}
// Initialisiere Dashboard-View
showView('dashboard');
await fetchData();
loadFromUrlParams();
// Automatisches Refresh: Aktualisiere nur Daten, nicht die Graphen (verhindert Zucken)
refreshInterval = setInterval(() => {
fetchData(true); // skipCharts = true
}, 30000);
// Lade Statistiken nach kurzer Verzögerung (forceReload = true beim ersten Laden)
setTimeout(() => loadStatistics(true), 1000);
};
</script>
</body>
</html>