Files
trading-daemon/dashboard/public/index.html
Melchior Reimers 64ffd9aa32
All checks were successful
Deployment / deploy-docker (push) Successful in 9s
updated
2026-01-25 17:46:47 +01:00

1287 lines
60 KiB
HTML
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.
<!DOCTYPE html>
<html lang="en">
<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>
<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;
}
canvas {
max-width: 100%;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-thumb {
background: #1e293b;
border-radius: 10px;
}
.config-sidebar {
width: 340px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
flex-shrink: 0;
}
.btn-primary {
background: #38bdf8;
color: #0b1120;
padding: 10px 20px;
border-radius: 10px;
font-weight: 700;
transition: all 0.2s;
box-shadow: 0 4px 14px 0 rgba(56, 189, 248, 0.3);
}
.btn-primary:hover {
background: #7dd3fc;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
}
.suggestion-box {
position: absolute;
z-index: 50;
width: 100%;
top: 100%;
left: 0;
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
margin-top: 4px;
max-height: 200px;
overflow-y: auto;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.suggestion-item:hover {
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
}
.field-label {
display: block;
font-size: 0.75rem;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.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;
}
.input-glass:focus {
border-color: #38bdf8;
background: rgba(56, 189, 248, 0.05);
}
.report-step {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
padding: 1.5rem;
border-radius: 12px;
overflow: hidden;
max-height: 1000px;
margin-bottom: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.step-active {
background: rgba(56, 189, 248, 0.05);
border: 1px solid rgba(56, 189, 248, 0.2);
opacity: 1 !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
}
.step-completed {
max-height: 80px;
opacity: 0.7;
cursor: pointer;
padding-top: 1rem;
padding-bottom: 1rem;
background: rgba(255, 255, 255, 0.02);
}
.step-completed:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.04);
border-color: rgba(56, 189, 248, 0.3);
}
.step-completed select,
.step-completed input,
.step-completed .relative,
.step-completed .pt-8,
.step-completed .grid {
display: none !important;
}
.step-summary {
font-size: 0.85rem;
color: #38bdf8;
font-weight: 700;
margin-top: 0.5rem;
background: rgba(56, 189, 248, 0.1);
padding: 6px 12px;
border-radius: 8px;
display: block;
width: fit-content;
border: 1px solid rgba(56, 189, 248, 0.2);
}
.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">
<!-- Sidebar -->
<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">
Antigravity</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('analytics')" id="nav-analytics"
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">📈</span> <span
class="hidden lg:inline">Report Builder</span></a>
<a href="#" onclick="showView('metadata')" id="nav-metadata"
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">🏢</span> <span
class="hidden lg:inline">Companies</span></a>
</nav>
<div class="p-6 mt-auto hidden lg:block">
<div class="glass p-4 text-xs text-sky-400 border-sky-500/30">Status: <span
class="text-green-400 animate-pulse">Connected</span></div>
</div>
</aside>
<!-- Main Content -->
<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 id="activeIsins" class="flex flex-wrap gap-2 mt-2"></div>
</div>
<button onclick="fetchData()" class="glass p-2 px-6 text-sky-400 font-bold hover:text-sky-200 transition">
REFRESH</button>
</header>
<!-- DASHBOARD VIEW -->
<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 glow">
<p class="text-slate-500 text-xs font-bold uppercase tracking-wider mb-2">Volume (7d)</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">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="grid grid-cols-1 xl:grid-cols-2 gap-8">
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Live Price Feed</h3>
<div class="h-80"><canvas id="priceChart"></canvas></div>
</div>
<div class="glass p-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Regional Distribution</h3>
<div class="h-80"><canvas id="continentChart"></canvas></div>
</div>
</div>
<!-- Neue Statistiken Sektion -->
<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>
<!-- Moving Average Graph -->
<div class="glass p-8 mb-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Moving Average: Tradezahlen & Volumen je Exchange</h3>
<div class="h-96"><canvas id="movingAverageChart"></canvas></div>
</div>
<!-- Volumen-Änderungen -->
<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>
<!-- Stock Trends -->
<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>
<!-- REPORT BUILDER VIEW -->
<div id="view-analytics" class="view hidden flex-1 flex overflow-hidden">
<!-- Configuration Sidebar -->
<div class="config-sidebar p-8 space-y-6 overflow-y-auto bg-slate-900/30" id="reportConfig">
<h3 class="text-sky-400 font-bold text-xs uppercase tracking-tighter mb-4">Step-by-Step Configuration
</h3>
<!-- Step 1: Time Range -->
<div class="report-step step-active" id="step1">
<label class="field-label">1. Choose Analysis Period</label>
<select id="timeRangePreset" class="input-glass" onchange="updateUrlParams(); proceedToStep(2)">
<option value="" disabled selected>Select... </option>
<option value="1">Today</option>
<option value="7">Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="ytd">Year to Date (YTD)</option>
<option value="year">Full Year 2026</option>
<option value="custom">Custom Range...</option>
</select>
<div id="customDates" class="hidden grid grid-cols-2 gap-2 mt-2">
<input type="date" id="dateFrom" class="input-glass text-xs p-2"
onchange="updateUrlParams(); checkCustomDates()">
<input type="date" id="dateTo" class="input-glass text-xs p-2"
onchange="updateUrlParams(); checkCustomDates()">
</div>
<div id="summary-step1" class="step-summary"></div>
</div>
<!-- Step 2: X-Axis -->
<div class="report-step hidden opacity-50" id="step2">
<label class="field-label">2. Primary Grouping (X-Axis)</label>
<select id="axisX" class="input-glass"
onchange="updateUrlParams(); proceedToStep(3); updateSubGroupOptions();">
<option value="" disabled selected>Select... </option>
<option value="day">Time (Daily)</option>
<option value="month">Time (Monthly)</option>
<option value="exchange">Exchange Origin</option>
<option value="continent">Region/Continent</option>
<option value="sector">Industry Sector</option>
<option value="isin">Specific ISIN</option>
</select>
<div id="summary-step2" class="step-summary"></div>
</div>
<!-- Step 3: Breakdown -->
<div class="report-step hidden opacity-50" id="step3">
<label class="field-label">3. Secondary Breakdown (Series)</label>
<select id="axisSub" class="input-glass" onchange="updateUrlParams(); proceedToStep(4)">
<option value="">None (Unified)</option>
<option value="isin">By Company / ISIN</option>
<option value="exchange">By Exchange</option>
<option value="continent">By Continent</option>
<option value="sector">By Sector</option>
<option value="exchange_continent">Exchange + Region</option>
<option value="exchange_sector">Exchange + Sector</option>
</select>
<div id="summary-step3" class="step-summary"></div>
</div>
<!-- Step 4: Y-Axis -->
<div class="report-step hidden opacity-50" id="step4">
<label class="field-label">4. Select Metric (Y-Axis)</label>
<select id="axisY" class="input-glass" onchange="updateUrlParams(); proceedToStep(5)">
<option value="" disabled selected>Select... </option>
<option value="volume">Trade Volume (€)</option>
<option value="count">Number of Trades</option>
<option value="avg_price">Average Performance</option>
<option value="all">Volume & Count (Dual Axis)</option>
</select>
<div id="summary-step4" class="step-summary"></div>
</div>
<!-- Step 5: Filters -->
<div class="report-step hidden opacity-50" id="step5">
<div class="relative">
<label class="field-label">5. Optional Company Filters</label>
<input type="text" id="isinSearch" autocomplete="off" placeholder="Search ISIN or Name..."
class="input-glass" onkeyup="handleSearch(event)">
<div id="suggestions" class="suggestion-box hidden"></div>
<div id="filterChips" class="flex flex-wrap gap-2 mt-4"></div>
</div>
<div class="pt-8 space-y-3">
<button id="btnGenerate" onclick="renderAnalyticsReport()"
class="btn-primary w-full shadow-sky-500/20">GENERATE REPORT</button>
<button onclick="resetReportConfig()"
class="w-full text-xs text-slate-500 hover:text-white transition">Reset
Configuration</button>
</div>
</div>
</div>
<div class="flex-1 p-8 overflow-hidden flex flex-col">
<div class="glass p-10 h-full flex flex-col shadow-2xl relative overflow-hidden">
<div class="flex justify-between items-center mb-10 z-10">
<h3 class="text-2xl font-bold" id="reportTitle">Report: Market Activity</h3>
<div class="flex glass p-1 rounded-lg">
<button class="p-2 px-4 text-xs font-bold rounded-md hover:bg-white/5"
onclick="setChartType('line')">Line</button>
<button class="p-2 px-4 text-xs font-bold rounded-md hover:bg-white/5"
onclick="setChartType('bar')">Bar</button>
</div>
</div>
<div class="flex-1 min-h-0 z-10 relative">
<div id="chartLoader"
class="hidden absolute inset-0 flex items-center justify-center bg-[#0b1120]/50 z-20">
<div class="spinner"></div>
</div>
<div id="chartError"
class="hidden absolute inset-0 flex items-center justify-center bg-red-900/20 z-20 p-10 text-center">
<div class="glass p-6 border-red-500/30">
<p class="text-red-400 font-bold mb-2">Analysis Failed</p>
<p id="errorMsg" class="text-xs text-slate-400"></p>
</div>
</div>
<canvas id="analyticsChart"></canvas>
</div>
<!-- Subtle watermark -->
<div class="absolute bottom-8 right-8 text-white/5 text-6xl font-black pointer-events-none">
ANALYTICS</div>
</div>
</div>
</div>
<!-- COMPANIES VIEW -->
<div id="view-metadata" class="view hidden flex-1 p-8 overflow-y-auto">
<div class="glass overflow-hidden">
<div class="p-8 border-b border-white/5 flex justify-between items-center">
<h3 class="text-xl font-bold">Metadata Repository</h3>
<input type="text" onkeyup="filterMetadata(this.value)"
placeholder="Search company, ISIN, sector..."
class="glass p-3 px-6 text-sm w-96 outline-none focus:border-sky-500">
</div>
<table class="w-full text-left">
<tbody id="metadataRows"></tbody>
</table>
</div>
</div>
</main>
<script>
const API = '/api';
let store = { trades: [], metadata: [], summary: [], pinnedIsins: [] };
let charts = {};
let currentChartType = 'bar';
let searchTimeout;
function showView(viewId) {
window.activeView = viewId;
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById(`view-${viewId}`).classList.remove('hidden');
document.querySelectorAll('#sidebar a').forEach(a => a.classList.remove('active-nav'));
document.getElementById(`nav-${viewId}`).classList.add('active-nav');
const titles = { 'dashboard': 'Market Overview', 'analytics': 'Custom Report Builder', 'metadata': 'Entity Metadata' };
document.getElementById('pageTitle').innerText = titles[viewId];
if (viewId === 'analytics') {
updateUrlParams();
} else {
fetchData();
}
}
function handlePresetChange() {
const v = document.getElementById('timeRangePreset').value;
document.getElementById('customDates').classList.toggle('hidden', v !== 'custom');
}
function updateSubGroupOptions() {
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub');
// Remove existing composite options first to avoid duplicates
// We need to reset the options or add them dynamically
// For simplicity, we'll just enable/disable standard ones and maybe append composite ones?
// Actually, let's just make sure they exist in the HTML (I will add them there)
// and toggling disabled state here.
Array.from(sub.options).forEach(opt => {
// Disable if same as X
opt.disabled = (opt.value === x);
// Composite options are special: only allow them if X is time-based (day/month)
if (['exchange_continent', 'exchange_sector'].includes(opt.value)) {
opt.disabled = !['day', 'month'].includes(x);
opt.hidden = !['day', 'month'].includes(x);
}
if (opt.value === x && sub.value === x) sub.value = '';
});
}
async function handleSearch(e) {
const q = e.target.value;
const sBox = document.getElementById('suggestions');
if (q.length < 2) { sBox.classList.add('hidden'); return; }
clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => {
const res = await fetch(`${API}/metadata/search?q=${q}`).then(r => r.json());
const data = res.dataset || [];
if (data.length > 0) {
sBox.innerHTML = data.map(item => `<div class="suggestion-item" onclick="addFilter('${item[0]}', '${item[1]}')">
<div class="font-bold">${item[0]}</div>
<div class="text-[10px] text-slate-500">${item[1]}</div>
</div>`).join('');
sBox.classList.remove('hidden');
} else {
sBox.classList.add('hidden');
}
}, 300);
}
function addFilter(isin, name) {
if (!store.pinnedIsins.find(p => p.isin === isin)) {
store.pinnedIsins.push({ isin, name });
updateFilterChips();
document.getElementById('isinSearch').value = '';
document.getElementById('suggestions').classList.add('hidden');
updateUrlParams();
if (window.activeView === 'analytics') proceedToStep(5);
}
}
function removeFilter(isin) {
store.pinnedIsins = store.pinnedIsins.filter(p => p.isin !== isin);
updateFilterChips();
updateUrlParams();
}
function updateFilterChips() {
const container = document.getElementById('filterChips');
const pins = document.getElementById('activeIsins');
const html = store.pinnedIsins.map(p => `
<div class="glass p-1 px-3 text-[10px] font-bold text-sky-400 border-sky-400/30 flex items-center bg-sky-400/10">
${p.isin} <span class="ml-2 cursor-pointer text-slate-500 hover:text-white" onclick="removeFilter('${p.isin}')">×</span>
</div>
`).join('');
if (container) container.innerHTML = html;
if (pins) pins.innerHTML = html;
}
function getDates() {
const preset = document.getElementById('timeRangePreset').value;
const now = new Date();
let from, to = now.toISOString().split('T')[0];
if (preset === '1') from = to;
else if (preset === '7') from = new Date(now.setDate(now.getDate() - 7)).toISOString().split('T')[0];
else if (preset === '30') from = new Date(now.setDate(now.getDate() - 30)).toISOString().split('T')[0];
else if (preset === 'ytd') from = new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0];
else if (preset === 'year') { from = '2026-01-01'; to = '2026-12-31'; }
else if (preset === 'custom') { from = document.getElementById('dateFrom').value; to = document.getElementById('dateTo').value; }
return { from, to };
}
async function fetchData() {
try {
// Verwende limit für trades um Performance zu verbessern
const [t, m, s] = await Promise.all([
fetch(`${API}/trades?days=7&limit=1000`).then(r => r.json()),
fetch(`${API}/metadata`).then(r => r.json()),
fetch(`${API}/summary?days=7`).then(r => r.json())
]);
store = { ...store, trades: t.dataset || [], metadata: m.dataset || [], summary: s.dataset || [] };
updateDashboard();
} catch (err) { console.error(err); }
}
function updateDashboard() {
let vol = store.trades.reduce((acc, r) => acc + (parseFloat(r[4]) * parseFloat(r[5] || 0)), 0);
document.getElementById('statVolume').innerText = vol >= 1e6 ? `${(vol / 1e6).toFixed(1)}M` : `${(vol / 1e3).toFixed(0)}k`;
document.getElementById('statTrades').innerText = store.trades.length.toLocaleString();
document.getElementById('statIsins').innerText = store.metadata.length.toLocaleString();
renderDashboardCharts();
fillMetadataTable();
// Lade Statistiken neu wenn Dashboard aktualisiert wird
if (window.activeView === 'dashboard') {
loadStatistics();
}
}
function setChartType(type) { currentChartType = type; renderAnalyticsReport(); }
async function renderAnalyticsReport() {
const btn = document.getElementById('btnGenerate');
const loader = document.getElementById('chartLoader');
const errBox = document.getElementById('chartError');
const dates = getDates();
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub').value;
const y = document.getElementById('axisY').value;
const isins = store.pinnedIsins.map(p => p.isin).join(',');
if (!dates.from || !x || !y) return;
btn.innerText = 'GENERATING...';
btn.disabled = true;
loader.classList.remove('hidden');
errBox.classList.add('hidden');
let url = `${API}/analytics?metric=${y}&group_by=${x}`;
if (dates.from) url += `&date_from=${dates.from}`;
if (dates.to) url += `&date_to=${dates.to}`;
if (isins) url += `&isins=${isins}`;
try {
// If multiple ISINs are selected but no breakdown is chosen,
// we automatically breakdown by ISIN to give them different colors.
let effectiveSub = sub;
if (!sub && store.pinnedIsins.length > 1 && x !== 'isin') {
effectiveSub = 'isin';
url += `&sub_group_by=isin`;
} else if (sub) {
url += `&sub_group_by=${sub}`;
}
const res = await fetch(url).then(r => {
if (!r.ok) throw new Error(`Server Error: ${r.status}`);
return r.json();
});
const data = res.dataset || [];
const ctx = document.getElementById('analyticsChart').getContext('2d');
let labels = [...new Set(data.map(r => r[0]))];
if (x === 'day' || x === 'month') labels.sort((a, b) => new Date(a) - new Date(b));
let datasets = [];
if (effectiveSub) {
let seriesNames = [...new Set(data.map(r => r[1]))];
seriesNames.forEach((name, idx) => {
const hue = (idx * 137.5) % 360;
if (y === 'all') {
// Dual axis for breakdown
// Volume Dataset
datasets.push({
label: `${name} (Vol)`,
data: labels.map(l => {
const row = data.find(r => r[0] === l && r[1] === name);
return row ? row[3] : 0; // value_volume is index 3
}),
backgroundColor: `hsla(${hue}, 75%, 50%, 0.7)`,
borderColor: `hsla(${hue}, 75%, 50%, 1)`,
borderWidth: 2,
yAxisID: 'y',
type: 'bar'
});
// Count Dataset
datasets.push({
label: `${name} (Cnt)`,
data: labels.map(l => {
const row = data.find(r => r[0] === l && r[1] === name);
return row ? row[2] : 0; // value_count is index 2
}),
backgroundColor: `hsla(${hue}, 75%, 30%, 0.7)`,
borderColor: `hsla(${hue}, 75%, 30%, 1)`,
borderWidth: 2,
yAxisID: 'y1',
type: 'line', // Always line for count to distinguish
pointStyle: 'rectRot'
});
} else {
// Standard single metric breakdown
datasets.push({
label: name,
data: labels.map(l => {
const row = data.find(r => r[0] === l && r[1] === name);
return row ? row[2] : 0;
}),
backgroundColor: `hsla(${hue}, 75%, 50%, 0.7)`,
borderColor: `hsla(${hue}, 75%, 50%, 1)`,
borderWidth: 2,
fill: currentChartType === 'line',
tension: 0.3,
yAxisID: 'y'
});
}
});
} else {
if (y === 'all') {
// Standard dual axis without breakdown
datasets.push({
label: 'Volume (€)',
data: labels.map(l => {
const row = data.find(r => r[0] === l);
return row ? row[2] : 0; // value_volume
}),
backgroundColor: '#38bdf866',
borderColor: '#38bdf8',
borderWidth: 2,
yAxisID: 'y',
type: 'bar'
});
datasets.push({
label: 'Count',
data: labels.map(l => {
const row = data.find(r => r[0] === l);
return row ? row[1] : 0; // value_count
}),
backgroundColor: '#fbbf2466',
borderColor: '#fbbf24',
borderWidth: 2,
yAxisID: 'y1',
type: 'line'
});
} else {
datasets.push({
label: y.toUpperCase(),
data: labels.map(l => {
const row = data.find(r => r[0] === l);
return row ? row[1] : 0;
}),
backgroundColor: '#38bdf866',
borderColor: '#38bdf8',
borderWidth: 2,
fill: currentChartType === 'line',
tension: 0.3,
yAxisID: 'y'
});
}
}
if (charts.analytics) charts.analytics.destroy();
charts.analytics = new Chart(ctx, {
type: currentChartType,
data: { labels: labels.map(l => (x === 'day' || x === 'month') ? new Date(l).toLocaleDateString() : l), datasets },
options: {
responsive: true, maintainAspectRatio: false,
animation: { duration: 800, easing: 'easeOutQuart' },
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
stacked: false,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' },
title: { display: true, text: 'Volume (€)', color: '#38bdf8' }
},
y1: {
type: 'linear',
display: y === 'all',
position: 'right',
stacked: false,
grid: { drawOnChartArea: false }, // only want the grid lines for one axis to show up
ticks: { color: '#fbbf24' },
title: { display: true, text: 'Trade Count', color: '#fbbf24' }
},
x: {
stacked: false,
grid: { display: false },
ticks: { color: '#64748b' }
}
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 20 }
},
tooltip: {
backgroundColor: '#1e293b',
titleColor: '#38bdf8',
bodyColor: '#e2e8f0',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1
}
}
}
});
document.getElementById('reportTitle').innerText = `Market Analytics: ${y.toUpperCase()} vs ${x.toUpperCase()}`;
} catch (err) {
console.error(err);
document.getElementById('errorMsg').innerText = err.message;
errBox.classList.remove('hidden');
} finally {
btn.innerText = 'GENERATE REPORT';
btn.disabled = false;
loader.classList.add('hidden');
}
}
function renderDashboardCharts() {
const trendCtx = document.getElementById('priceChart').getContext('2d');
const samples = [...store.trades].sort((a, b) => new Date(a[3]) - new Date(b[3])).slice(-100);
if (charts.price) charts.price.destroy();
charts.price = new Chart(trendCtx, {
type: 'line',
data: { labels: samples.map(r => new Date(r[3]).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })), datasets: [{ data: samples.map(r => r[4]), borderColor: '#38bdf8', borderWidth: 2, tension: 0.4, pointRadius: 0 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: 'rgba(255,255,255,0.05)' } } } }
});
const contCtx = document.getElementById('continentChart').getContext('2d');
if (charts.continent) charts.continent.destroy();
const labels = store.summary.map(r => r[0]);
const baseColors = ['#38bdf8', '#f43f5e', '#10b981', '#fbbf24', '#8b5cf6', '#f97316', '#ec4899', '#6366f1'];
charts.continent = new Chart(contCtx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: store.summary.map(r => r[2]),
backgroundColor: labels.map((l, i) => l.toLowerCase() === 'unknown' ? '#000000' : baseColors[i % baseColors.length]),
borderWidth: 0
}]
},
options: { responsive: true, maintainAspectRatio: false, cutout: '75%', plugins: { legend: { position: 'right', labels: { color: '#94a3b8' } } } }
});
}
function proceedToStep(n) {
document.querySelectorAll('.report-step').forEach((s, idx) => {
const stepNum = idx + 1;
// Active step is the one currently being configured
s.classList.toggle('step-active', stepNum === n);
// Completed steps are those before the current one, but NOT if they are active
s.classList.toggle('step-completed', stepNum < n && stepNum !== n);
// Show all steps but fade future ones slightly
s.classList.remove('hidden');
if (stepNum > n) {
s.classList.add('opacity-50');
} else {
s.classList.remove('opacity-50');
}
const summary = document.getElementById(`summary-step${stepNum}`);
if (summary) {
// Only show summary for non-active steps that have a selection
if (stepNum !== n) {
const input = s.querySelector('select, input');
let val = input ? (input.options && input.selectedIndex >= 0 ? input.options[input.selectedIndex].text : input.value) : '';
if (val && val !== 'Select...') {
summary.innerText = val;
summary.classList.remove('hidden');
} else {
summary.classList.add('hidden');
}
// Allow clicking headers of any step to go back/forward
s.onclick = () => proceedToStep(stepNum);
} else {
summary.classList.add('hidden');
s.onclick = null;
}
}
});
updateUrlParams();
}
function checkCustomDates() {
const from = document.getElementById('dateFrom').value;
const to = document.getElementById('dateTo').value;
if (from && to) proceedToStep(2);
}
function updateUrlParams() {
const params = new URLSearchParams(window.location.search);
const time = document.getElementById('timeRangePreset').value;
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub') ? document.getElementById('axisSub').value : '';
const y = document.getElementById('axisY').value;
const isins = store.pinnedIsins.map(p => p.isin).join(',');
if (time) params.set('time', time); else params.delete('time');
if (x) params.set('x', x); else params.delete('x');
if (sub) params.set('sub', sub); else params.delete('sub');
if (y) params.set('y', y); else params.delete('y');
if (isins) params.set('isins', isins); else params.delete('isins');
if (window.activeView) params.set('view', window.activeView);
// Important: Use hash only if it exists
const cleanUrl = window.location.pathname + '?' + params.toString();
window.history.replaceState({ selection: params.toString() }, '', cleanUrl);
}
function syncStateFromUrl() {
const params = new URLSearchParams(window.location.search);
const time = params.get('time');
const x = params.get('x');
const sub = params.get('sub');
const y = params.get('y');
const isins = params.get('isins');
const view = params.get('view');
if (view) showView(view);
if (time) { document.getElementById('timeRangePreset').value = time; proceedToStep(2); }
if (x) { document.getElementById('axisX').value = x; updateSubGroupOptions(); proceedToStep(3); }
if (sub) { document.getElementById('axisSub').value = sub; proceedToStep(4); }
if (y) { document.getElementById('axisY').value = y; proceedToStep(5); }
if (isins) {
setTimeout(() => {
isins.split(',').forEach(id => {
const ent = store.metadata.find(m => m[0] === id);
addFilter(id, ent ? ent[1] : id);
});
if (x && y && time) renderAnalyticsReport();
}, 800);
} else if (x && y && time) {
// Auto-generate report even without ISIN filters
setTimeout(() => renderAnalyticsReport(), 1000);
}
}
function resetReportConfig() {
document.querySelectorAll('.report-step').forEach((s, i) => { if (i > 0) s.classList.add('hidden', 'opacity-50'); else s.classList.add('step-active'); });
document.getElementById('timeRangePreset').value = '';
document.getElementById('axisX').value = '';
document.getElementById('axisSub').value = '';
document.getElementById('axisY').value = '';
store.pinnedIsins = [];
updateFilterChips();
updateUrlParams();
}
function fillMetadataTable() {
const tbody = document.getElementById('metadataRows');
tbody.innerHTML = store.metadata.map(r => `
<tr class="hover:bg-white/5 transition border-b border-white/5 cursor-pointer" onclick="deepLinkToAnalytics('${r[0]}', '${r[1]}')">
<td class="p-5 px-8 font-mono text-sky-400 font-bold">${r[0]}</td>
<td class="p-5 font-semibold text-slate-200">${r[1]}</td>
<td class="p-5 text-slate-500">${r[2]}</td>
<td class="p-5"><span class="bg-white/5 px-2 py-1 rounded text-[10px] text-slate-400 font-bold uppercase">${r[4] || 'UNKNOWN'}</span></td>
</tr>
`).join('');
}
function deepLinkToAnalytics(isin, name) {
resetReportConfig();
document.getElementById('timeRangePreset').value = '30';
document.getElementById('axisX').value = 'day';
document.getElementById('axisY').value = 'volume';
store.pinnedIsins = [{ isin, name }];
updateFilterChips();
showView('analytics');
for (let i = 1; i <= 5; i++) proceedToStep(i);
renderAnalyticsReport();
}
function filterMetadata(q) {
const rows = document.querySelectorAll('#metadataRows tr');
rows.forEach(r => r.style.display = r.innerText.toLowerCase().includes(q.toLowerCase()) ? '' : 'none');
}
async function loadStatistics() {
const days = document.getElementById('statisticsPeriod').value;
await Promise.all([
loadMovingAverage(days),
loadVolumeChanges(days),
loadStockTrends(days)
]);
}
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 || [];
if (!data.length) {
console.log('No moving average data available');
return;
}
const ctx = document.getElementById('movingAverageChart').getContext('2d');
if (charts.movingAverage) charts.movingAverage.destroy();
// Finde Spaltenindizes
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');
const maCountIdx = columns.findIndex(c => c.name === 'ma_count');
const maVolumeIdx = columns.findIndex(c => c.name === 'ma_volume');
// Gruppiere nach Exchange
const exchanges = [...new Set(data.map(r => r[exchangeIdx]))];
const dates = [...new Set(data.map(r => r[dateIdx]))].sort();
const datasets = [];
const colors = ['#38bdf8', '#f43f5e', '#10b981', '#fbbf24', '#8b5cf6'];
// Trade Count Datasets
exchanges.forEach((exchange, idx) => {
datasets.push({
label: `${exchange} - Trade Count`,
data: dates.map(d => {
const row = data.find(r => r[dateIdx] === d && r[exchangeIdx] === exchange);
return row ? (row[countIdx] || 0) : 0;
}),
borderColor: colors[idx % colors.length],
backgroundColor: colors[idx % colors.length] + '33',
borderWidth: 2,
yAxisID: 'y',
tension: 0.3
});
});
// Moving Average Datasets
exchanges.forEach((exchange, idx) => {
datasets.push({
label: `${exchange} - MA Count`,
data: dates.map(d => {
const row = data.find(r => r[dateIdx] === d && r[exchangeIdx] === exchange);
return row ? (row[maCountIdx] || 0) : 0;
}),
borderColor: colors[idx % colors.length],
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [5, 5],
yAxisID: 'y',
tension: 0.3
});
});
// Volume Datasets
exchanges.forEach((exchange, idx) => {
datasets.push({
label: `${exchange} - Volume`,
data: dates.map(d => {
const row = data.find(r => r[dateIdx] === d && r[exchangeIdx] === exchange);
return row ? (row[volumeIdx] || 0) : 0;
}),
borderColor: colors[(idx + 2) % colors.length],
backgroundColor: colors[(idx + 2) % colors.length] + '33',
borderWidth: 2,
yAxisID: 'y1',
tension: 0.3
});
});
charts.movingAverage = 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,
position: 'left',
title: { display: true, text: 'Trade Count', color: '#94a3b8' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' }
},
y1: {
type: 'linear',
display: true,
position: 'right',
title: { display: true, text: 'Volume (€)', color: '#94a3b8' },
grid: { drawOnChartArea: false },
ticks: { color: '#64748b' }
},
x: {
grid: { display: false },
ticks: { color: '#64748b' }
}
},
plugins: {
legend: {
display: true,
position: 'bottom',
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
}
}
}
});
} 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 ctx = document.getElementById('volumeChangesChart').getContext('2d');
if (charts.volumeChanges) charts.volumeChanges.destroy();
// Finde Spaltenindizes
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: {
backgroundColor: '#1e293b',
titleColor: '#38bdf8',
bodyColor: '#e2e8f0',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1,
callbacks: {
afterLabel: (context) => {
const idx = context.dataIndex;
const trend = data[idx][trendIdx]; // trend
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 ctx = document.getElementById('stockTrendsChart').getContext('2d');
const tableContainer = document.getElementById('stockTrendsTable');
if (charts.stockTrends) charts.stockTrends.destroy();
// Finde Spaltenindizes
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');
// Sortiere nach Volumen
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);
const countChanges = sorted.map(r => r[countChangeIdx] || 0);
const volumeChanges = sorted.map(r => r[volumeChangeIdx] || 0);
charts.stockTrends = new Chart(ctx, {
type: 'bar',
data: {
labels: isins.map(i => i.substring(0, 12) + '...'),
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 }
},
tooltip: {
backgroundColor: '#1e293b',
titleColor: '#38bdf8',
bodyColor: '#e2e8f0',
borderColor: 'rgba(255,255,255,0.1)',
borderWidth: 1
}
}
}
});
// Erstelle Tabelle
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]}</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);
}
}
window.onload = async () => {
await fetchData();
syncStateFromUrl();
setInterval(fetchData, 30000);
// Lade Statistiken beim Start
setTimeout(() => loadStatistics(), 1000);
};
</script>
</body>
</html>