feat: google cloud billing style analytics and robust reporting API
All checks were successful
Deployment / deploy-docker (push) Successful in 15s

This commit is contained in:
Melchior Reimers
2026-01-23 19:15:48 +01:00
parent dbd4fbfb47
commit a4aeea9d6c
2 changed files with 291 additions and 286 deletions

View File

@@ -22,21 +22,6 @@
border-radius: 16px;
}
.glass-btn {
background: rgba(56, 189, 248, 0.1);
border: 1px solid rgba(56, 189, 248, 0.2);
transition: all 0.3s;
}
.glass-btn:hover {
background: rgba(56, 189, 248, 0.2);
border-color: #38bdf8;
}
.glow {
box-shadow: 0 0 20px rgba(56, 189, 248, 0.1);
}
.active-nav {
background: rgba(56, 189, 248, 0.1);
color: #38bdf8;
@@ -55,12 +40,31 @@
background: #1e293b;
border-radius: 10px;
}
.config-sidebar {
width: 320px;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.btn-primary {
background: #38bdf8;
color: #0b1120;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
transition: all 0.2s;
}
.btn-primary:hover {
background: #7dd3fc;
transform: translateY(-1px);
}
</style>
</head>
<body class="flex min-h-screen overflow-hidden">
<!-- Sidebar -->
<!-- Sidebar (Main Nav) -->
<aside class="w-64 glass m-4 mr-0 flex flex-col hidden lg:flex">
<div class="p-6">
<h1 class="text-2xl font-bold bg-gradient-to-r from-sky-400 to-blue-500 bg-clip-text text-transparent">
@@ -68,134 +72,148 @@
</div>
<nav class="flex-1 px-4 space-y-1 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> Dashboard
</a>
class="flex items-center p-3 rounded-xl transition active-nav"><span class="mr-3">📊</span>
Dashboard</a>
<a href="#" onclick="showView('analytics')" id="nav-analytics"
class="flex items-center p-3 rounded-xl transition">
<span class="mr-3">📈</span> Analytics Hub
</a>
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">📈</span> Reports (GC Style)</a>
<a href="#" onclick="showView('metadata')" id="nav-metadata"
class="flex items-center p-3 rounded-xl transition">
<span class="mr-3">🏢</span> Companies
</a>
class="flex items-center p-3 rounded-xl transition"><span class="mr-3">🏢</span> Companies</a>
</nav>
<div class="p-6 mt-auto">
<div class="glass p-4 text-xs text-sky-400 border-sky-500/30">
Data Stream: <span class="text-green-400 animate-pulse">● Live</span>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 p-8 overflow-y-auto">
<header class="flex justify-between items-center mb-10">
<!-- Main Content Area -->
<main class="flex-1 flex flex-col min-w-0">
<header class="p-8 pb-0 flex justify-between items-center">
<div id="viewHeader">
<h2 class="text-3xl font-bold">Market Intelligence</h2>
<p class="text-slate-400 mt-1">Cross-exchange analysis and real-time visualization.</p>
</div>
<div class="flex items-center space-x-4">
<div id="activeIsins" class="flex -space-x-2">
<!-- Pinned ISINs will show up here -->
</div>
<select id="timeRange" class="glass px-4 py-2 text-sm outline-none focus:ring-1 ring-sky-500"
onchange="fetchData()">
<option value="1">Last 24 Hours</option>
<option value="7" selected>Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="90">Last Quarter</option>
</select>
<button onclick="fetchData()" class="glass p-2 px-4 text-sky-400 hover:text-sky-300"></button>
<div id="globalControls" class="flex items-center space-x-4">
<div id="activeIsins" class="flex -space-x-2"></div>
<button onclick="fetchData()" class="glass p-2 px-4 text-sky-400 hover:text-sky-300">↻ Refresh</button>
</div>
</header>
<!-- DASHBOARD VIEW -->
<div id="view-dashboard" class="view">
<div id="view-dashboard" class="view flex-1 p-8 overflow-y-auto">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div class="glass p-6 glow border-l-4 border-sky-500">
<div class="glass p-6 border-l-4 border-sky-500">
<p class="text-slate-400 text-xs uppercase tracking-widest mb-2">Transaction Volume</p>
<h3 id="statVolume" class="text-4xl font-bold">€0.0k</h3>
<h3 id="statVolume" class="text-4xl font-bold">--</h3>
</div>
<div class="glass p-6 border-l-4 border-amber-500">
<p class="text-slate-400 text-xs uppercase tracking-widest mb-2">Total Executions</p>
<h3 id="statTrades" class="text-4xl font-bold">0</h3>
<h3 id="statTrades" class="text-4xl font-bold">--</h3>
</div>
<div class="glass p-6 border-l-4 border-emerald-500">
<p class="text-slate-400 text-xs uppercase tracking-widest mb-2">Unique Assets</p>
<h3 id="statIsins" class="text-4xl font-bold">0</h3>
<h3 id="statIsins" class="text-4xl font-bold">--</h3>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-10">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8">
<div class="glass p-8">
<h3 class="text-xl font-bold mb-6">Execution Price Trend</h3>
<h3 class="text-xl font-bold mb-6">Price Trend (Recent)</h3>
<div class="h-80"><canvas id="priceChart"></canvas></div>
</div>
<div class="glass p-8">
<h3 class="text-xl font-bold mb-6">Geographic Distribution</h3>
<h3 class="text-xl font-bold mb-6">Market Distribution</h3>
<div class="h-80"><canvas id="continentChart"></canvas></div>
</div>
</div>
</div>
<!-- ANALYTICS VIEW -->
<div id="view-analytics" class="view hidden">
<div class="glass p-8 mb-8">
<div class="flex justify-between items-center mb-8">
<h3 class="text-xl font-bold">Custom Feature Comparison</h3>
<div class="flex space-x-4">
<select id="axisX" class="glass px-3 py-1 text-xs" onchange="renderCustomChart()">
<option value="time">Time</option>
<!-- ANALYTICS VIEW (Google Cloud Billing Style) -->
<div id="view-analytics" class="view hidden flex-1 flex overflow-hidden">
<!-- Configuration Sidebar -->
<div class="config-sidebar p-6 space-y-8 overflow-y-auto bg-slate-900/20">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Time
Range</label>
<div class="space-y-2">
<select id="timeRangePreset" class="w-full glass p-2 text-sm outline-none"
onchange="handlePresetChange()">
<option value="1">1 Day</option>
<option value="7" selected>Last 7 Days</option>
<option value="30">Last 30 Days</option>
<option value="ytd">YTD (Year to Date)</option>
<option value="year">Current Year</option>
<option value="custom">Custom Range</option>
</select>
<div id="customDates" class="hidden space-y-2 pt-2">
<input type="date" id="dateFrom" class="w-full glass p-2 text-xs">
<input type="date" id="dateTo" class="w-full glass p-2 text-xs">
</div>
</div>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Group by
(X-Axis)</label>
<select id="axisX" class="w-full glass p-2 text-sm outline-none">
<option value="day">Day</option>
<option value="month">Month</option>
<option value="exchange">Exchange</option>
<option value="continent">Continent</option>
<option value="sector">Sector</option>
<option value="name">Company Name</option>
</select>
<select id="axisY" class="glass px-3 py-1 text-xs" onchange="renderCustomChart()">
<option value="volume">Trade Volume</option>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Breakdown by
(Series)</label>
<select id="axisSub" class="w-full glass p-2 text-sm outline-none">
<option value="">None</option>
<option value="exchange">Exchange</option>
<option value="continent">Continent</option>
<option value="sector">Sector</option>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Metric
(Y-Axis)</label>
<select id="axisY" class="w-full glass p-2 text-sm outline-none">
<option value="volume">Trade Volume (€)</option>
<option value="count">Trade Count</option>
<option value="avg_price">Avg. Price</option>
</select>
</div>
</div>
<div class="h-96"><canvas id="customChart"></canvas></div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Filters</label>
<input type="text" id="isinFilter" placeholder="Filter ISINs (split by ,)"
class="w-full glass p-2 text-xs outline-none">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="glass p-6">
<h3 class="text-lg font-bold mb-6">Exchange Dominance</h3>
<div class="h-64"><canvas id="exchangeChart"></canvas></div>
<button onclick="renderAnalyticsReport()" class="btn-primary w-full">Run Report</button>
</div>
<div class="glass p-6">
<h3 class="text-lg font-bold mb-6">Market Dynamics</h3>
<div id="dynamicStats" class="space-y-4">
<!-- Stats injected here -->
<!-- Report Content Area -->
<div class="flex-1 p-8 overflow-y-auto">
<div class="glass p-8 h-full flex flex-col">
<div class="flex justify-between items-center mb-8">
<h3 class="text-xl font-bold" id="reportTitle">Custom Trade Analysis</h3>
<div class="flex space-x-2">
<button class="glass p-2 px-3 text-xs" onclick="setChartType('line')">Line</button>
<button class="glass p-2 px-3 text-xs" onclick="setChartType('bar')">Bar</button>
</div>
</div>
<div class="flex-1 min-h-0"><canvas id="analyticsChart"></canvas></div>
</div>
</div>
</div>
<!-- COMPANIES VIEW -->
<div id="view-metadata" class="view hidden">
<div id="view-metadata" class="view hidden flex-1 p-8 overflow-y-auto">
<div class="glass overflow-hidden">
<div class="p-6 border-b border-white/5 flex justify-between items-center">
<h3 class="text-xl font-bold text-sky-400">ISIN Metadata Directory</h3>
<input type="text" id="metadataSearch" onkeyup="filterMetadata()"
placeholder="Search ISIN, Name or Sector..."
class="glass px-4 py-2 text-sm w-80 outline-none focus:border-sky-500">
<input type="text" id="metadataSearch" onkeyup="filterMetadata()" placeholder="Search..."
class="glass px-4 py-2 text-sm w-80 outline-none">
</div>
<table class="w-full text-left">
<thead class="bg-white/5 text-slate-400 text-xs uppercase tracking-tighter font-bold">
<tr>
<th class="p-4 px-6">ISIN</th>
<th class="p-4">Entity Legal Name</th>
<th class="p-4">Origin</th>
<th class="p-4">Continent</th>
<th class="p-4">Market Sector</th>
<th class="p-4 text-center">Actions</th>
</tr>
</thead>
<tbody id="metadataRows" class="divide-y divide-white/5 text-sm">
<!-- Filled by JS -->
</tbody>
<table class="w-full text-left text-sm">
<tbody id="metadataRows"></tbody>
</table>
</div>
</div>
@@ -203,207 +221,153 @@
<script>
const API = '/api';
let store = { trades: [], metadata: [], summary: [], comparison: [], pinnedIsins: [] };
let store = { trades: [], metadata: [], summary: [], pinnedIsins: [] };
let charts = {};
async function fetchData() {
try {
const days = document.getElementById('timeRange').value;
const [t, m, s, c] = await Promise.all([
fetch(`${API}/trades?days=${days}`).then(r => r.json()),
fetch(`${API}/metadata`).then(r => r.json()),
fetch(`${API}/summary`).then(r => r.json()),
fetch(`${API}/comparison?days=${days}`).then(r => r.json())
]);
store = { trades: t.dataset || [], metadata: m.dataset || [], summary: s.dataset || [], comparison: c.dataset || [], pinnedIsins: store.pinnedIsins };
updateUI();
} catch (err) { console.error("Fetch error:", err); }
}
function updateUI() {
updateStats();
renderDashboardCharts();
renderCustomChart();
renderExchangeChart();
fillMetadataTable();
}
function updateStats() {
document.getElementById('statTrades').innerText = store.trades.length.toLocaleString();
document.getElementById('statIsins').innerText = store.metadata.length.toLocaleString();
let vol = store.trades.reduce((acc, r) => acc + (parseFloat(r[4]) * parseFloat(r[5])), 0);
document.getElementById('statVolume').innerText = vol >= 1e6 ? `${(vol / 1e6).toFixed(2)}M` : `${(vol / 1e3).toFixed(1)}k`;
}
let currentChartType = 'bar';
function showView(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');
window.activeView = viewId;
fetchData();
if (viewId === 'analytics') renderAnalyticsReport(); else fetchData();
}
function handlePresetChange() {
const v = document.getElementById('timeRangePreset').value;
document.getElementById('customDates').classList.toggle('hidden', v !== 'custom');
}
function getDates() {
const preset = document.getElementById('timeRangePreset').value;
const now = new Date();
let from, to = now.toISOString().split('T')[0];
if (preset === '1') from = new Date(now.setDate(now.getDate() - 1)).toISOString().split('T')[0];
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 = new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0]; to = new Date(now.getFullYear(), 11, 31).toISOString().split('T')[0]; }
else if (preset === 'custom') { from = document.getElementById('dateFrom').value; to = document.getElementById('dateTo').value; }
return { from, to };
}
async function fetchData() {
try {
const [t, m, s] = await Promise.all([
fetch(`${API}/trades?days=7`).then(r => r.json()),
fetch(`${API}/metadata`).then(r => r.json()),
fetch(`${API}/summary`).then(r => r.json())
]);
store = { ...store, trades: t.dataset || [], metadata: m.dataset || [], summary: s.dataset || [] };
updateDashboard();
fillMetadataTable();
} 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(2)}M` : `${(vol / 1e3).toFixed(1)}k`;
document.getElementById('statTrades').innerText = store.trades.length.toLocaleString();
document.getElementById('statIsins').innerText = store.metadata.length.toLocaleString();
renderDashboardCharts();
}
function setChartType(type) { currentChartType = type; renderAnalyticsReport(); }
async function renderAnalyticsReport() {
const dates = getDates();
const x = document.getElementById('axisX').value;
const sub = document.getElementById('axisSub').value;
const y = document.getElementById('axisY').value;
const isins = document.getElementById('isinFilter').value;
let url = `${API}/analytics?metric=${y}&group_by=${x}`;
if (sub) url += `&sub_group_by=${sub}`;
if (dates.from) url += `&date_from=${dates.from}`;
if (dates.to) url += `&date_to=${dates.to}`;
if (isins) url += `&isins=${isins}`;
try {
const res = await fetch(url).then(r => r.json());
const data = res.dataset || [];
const ctx = document.getElementById('analyticsChart').getContext('2d');
// Grouping logic for stacked/multi-series charts
let labels = [...new Set(data.map(r => r[0]))];
let datasets = [];
if (sub) {
let series = [...new Set(data.map(r => r[1]))];
series.forEach((s, idx) => {
datasets.push({
label: s,
data: labels.map(l => {
let match = data.find(r => r[0] === l && r[1] === s);
return match ? match[2] : 0;
}),
backgroundColor: `hsla(${idx * 40 + 200}, 70%, 50%, 0.8)`,
borderColor: `hsla(${idx * 40 + 200}, 70%, 50%, 1)`,
borderWidth: 2,
fill: currentChartType === 'line'
});
});
} else {
datasets.push({
label: y,
data: data.map(r => r[1]),
backgroundColor: '#38bdf888',
borderColor: '#38bdf8',
borderWidth: 2,
fill: currentChartType === 'line'
});
}
if (charts.analytics) charts.analytics.destroy();
charts.analytics = new Chart(ctx, {
type: currentChartType,
data: { labels: labels.map(l => x === 'day' ? new Date(l).toLocaleDateString() : l), datasets },
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { stacked: true, grid: { color: 'rgba(255,255,255,0.05)' } }, x: { stacked: true, grid: { display: false } } },
plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } }
}
});
} catch (err) { console.error(err); }
}
function renderDashboardCharts() {
// Price Trend
const trendCtx = document.getElementById('priceChart').getContext('2d');
const sorted = [...store.trades].sort((a, b) => new Date(a[3]) - new Date(b[3]));
const samples = sorted.slice(-100);
const samples = [...store.trades].sort((a, b) => new Date(a[3]) - new Date(b[3])).slice(-50);
if (charts.price) charts.price.destroy();
charts.price = new Chart(trendCtx, {
type: 'line',
data: {
labels: samples.map(r => {
const d = new Date(r[3]);
return isNaN(d) ? '?' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}),
datasets: [{
label: 'Execution Price',
data: samples.map(r => parseFloat(r[4])),
borderColor: '#38bdf8',
borderWidth: 2,
fill: true,
backgroundColor: 'rgba(56, 189, 248, 0.05)',
tension: 0.4,
pointRadius: 0
}]
},
options: { responsive: true, maintainAspectRatio: false, scales: { x: { grid: { display: false } }, y: { grid: { color: 'rgba(255,255,255,0.05)' } } }, plugins: { legend: { display: false } } }
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', tension: 0.4, pointRadius: 0 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
// Continent Pie
const contCtx = document.getElementById('continentChart').getContext('2d');
if (charts.continent) charts.continent.destroy();
charts.continent = new Chart(contCtx, {
type: 'doughnut',
data: {
labels: store.summary.map(r => r[0]),
datasets: [{
data: store.summary.map(r => r[2]),
backgroundColor: ['#38bdf8', '#fbbf24', '#10b981', '#f87171', '#818cf8', '#a78bfa'],
borderWidth: 0,
hoverOffset: 20
}]
},
options: { responsive: true, maintainAspectRatio: false, cutout: '70%', plugins: { legend: { position: 'right', labels: { color: '#94a3b8', font: { size: 12 } } } } }
});
}
function renderCustomChart() {
const ctx = document.getElementById('customChart').getContext('2d');
const x = document.getElementById('axisX').value;
const y = document.getElementById('axisY').value;
// Simple Dynamic Aggregation
let grouped = {};
store.comparison.forEach(r => {
const label = x === 'time' ? new Date(r[1]).toLocaleDateString() : r[0]; // Simplified
if (!grouped[label]) grouped[label] = { val: 0, count: 0, prices: 0 };
grouped[label].val += r[2]; // Volume
grouped[label].count += r[3]; // Count
grouped[label].prices += r[2] / (r[3] || 1); // Mock avg price
});
const labels = Object.keys(grouped);
let finalData = labels.map(l => {
if (y === 'volume') return grouped[l].val;
if (y === 'count') return grouped[l].count;
return grouped[l].prices / (grouped[l].count || 1);
});
if (charts.custom) charts.custom.destroy();
charts.custom = new Chart(ctx, {
type: x === 'time' ? 'line' : 'bar',
data: {
labels,
datasets: [{
label: `${y} by ${x}`,
data: finalData,
backgroundColor: '#38bdf8',
borderColor: '#38bdf8',
borderRadius: 8
}]
},
options: { responsive: true, maintainAspectRatio: false }
});
}
function renderExchangeChart() {
const ctx = document.getElementById('exchangeChart').getContext('2d');
const dataMap = {};
store.trades.forEach(r => {
dataMap[r[0]] = (dataMap[r[0]] || 0) + 1;
});
if (charts.exchange) charts.exchange.destroy();
charts.exchange = new Chart(ctx, {
type: 'bar',
data: {
labels: Object.keys(dataMap),
datasets: [{
data: Object.values(dataMap),
backgroundColor: ['#38bdf8', '#fbbf24'],
borderRadius: 10
}]
},
options: { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
data: { labels: store.summary.map(r => r[0]), datasets: [{ data: store.summary.map(r => r[2]), backgroundColor: ['#38bdf8', '#fbbf24', '#10b981', '#f87171'], borderWidth: 0 }] },
options: { responsive: true, maintainAspectRatio: false, cutout: '70%', plugins: { legend: { position: 'bottom', labels: { color: '#94a3b8' } } } }
});
}
function fillMetadataTable() {
const tbody = document.getElementById('metadataRows');
tbody.innerHTML = '';
store.metadata.forEach(r => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-white/5 transition cursor-default group';
tr.innerHTML = `
tbody.innerHTML = store.metadata.map(r => `
<tr class="hover:bg-white/5 transition border-b border-white/5">
<td class="p-4 px-6 font-mono text-sky-400 font-bold">${r[0]}</td>
<td class="p-4 font-semibold text-slate-200">${r[1]}</td>
<td class="p-4"><span class="px-2 py-1 bg-sky-500/10 text-sky-400 rounded-lg text-xs font-bold ring-1 ring-sky-500/20">${r[2]}</span></td>
<td class="p-4 text-slate-400">${r[3]}</td>
<td class="p-4"><span class="text-slate-500 italic">${r[4]}</span></td>
<td class="p-4 text-center">
<button onclick="pinIsin('${r[0]}')" class="glass-btn p-2 rounded-lg text-lg opacity-0 group-hover:opacity-100">📌</button>
</td>
`;
tbody.appendChild(tr);
});
<td class="p-4 font-semibold">${r[1]}</td>
<td class="p-4 text-slate-500">${r[2]}</td>
<td class="p-4 text-slate-400 text-xs italic">${r[4]}</td>
</tr>
`).join('');
}
function pinIsin(isin) {
if (!store.pinnedIsins.includes(isin)) {
store.pinnedIsins.push(isin);
updatePinnedUI();
}
}
function updatePinnedUI() {
const container = document.getElementById('activeIsins');
container.innerHTML = '';
store.pinnedIsins.forEach(isin => {
const chip = document.createElement('div');
chip.className = 'glass p-1 px-3 text-xs text-sky-400 border-sky-500/50 flex items-center bg-sky-950/50 cursor-pointer hover:bg-sky-900 transition';
chip.innerHTML = `${isin} <span class="ml-2 text-slate-500" onclick="unpinIsin('${isin}')">×</span>`;
container.appendChild(chip);
});
}
function unpinIsin(isin) {
store.pinnedIsins = store.pinnedIsins.filter(i => i !== isin);
updatePinnedUI();
}
function filterMetadata() {
const q = document.getElementById('metadataSearch').value.toLowerCase();
const rows = document.querySelectorAll('#metadataRows tr');
rows.forEach(r => {
const text = r.innerText.toLowerCase();
r.style.display = text.includes(q) ? '' : 'none';
});
}
window.onload = () => { window.activeView = 'dashboard'; fetchData(); setInterval(fetchData, 30000); };
window.onload = () => { fetchData(); setInterval(fetchData, 30000); };
</script>
</body>

View File

@@ -73,22 +73,63 @@ async def get_summary():
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/comparison")
async def get_comparison(days: int = 7):
# Aggregated volume per exchange per day
# QuestDB date_trunc('day', timestamp) is good.
# We ensure we get exchange, day, and metrics.
query = f"""
select
exchange,
date_trunc('day', timestamp) as day,
sum(price * quantity) as daily_volume,
count(*) as trade_count
from trades
where timestamp > dateadd('d', -{days}, now())
group by exchange, day
order by day asc
"""
@app.get("/api/analytics")
async def get_analytics(
metric: str = "volume",
group_by: str = "day",
sub_group_by: str = None,
date_from: str = None,
date_to: str = None,
isins: str = None,
continents: str = None
):
metrics_map = {
"volume": "sum(t.price * t.quantity)",
"count": "count(*)",
"avg_price": "avg(t.price)"
}
groups_map = {
"day": "date_trunc('day', t.timestamp)",
"month": "date_trunc('month', t.timestamp)",
"exchange": "t.exchange",
"isin": "t.isin",
"name": "coalesce(m.name, t.isin)",
"continent": "coalesce(m.continent, 'Unknown')",
"sector": "coalesce(m.sector, 'Unknown')"
}
selected_metric = metrics_map.get(metric, metrics_map["volume"])
selected_group = groups_map.get(group_by, groups_map["day"])
# We always join metadata to allow filtering by continent/sector even if not grouping by them
query = f"select {selected_group} as label"
if sub_group_by and sub_group_by in groups_map:
query += f", {groups_map[sub_group_by]} as sub_label"
query += f", {selected_metric} as value from trades t"
query += " left join metadata m on t.isin = m.isin where 1=1"
if date_from:
query += f" and t.timestamp >= '{date_from}'"
if date_to:
query += f" and t.timestamp <= '{date_to}'"
if isins:
isins_list = ",".join([f"'{i.strip()}'" for i in isins.split(",")])
query += f" and t.isin in ({isins_list})"
if continents:
cont_list = ",".join([f"'{c.strip()}'" for c in continents.split(",")])
query += f" and m.continent in ({cont_list})"
query += " group by label"
if sub_group_by and sub_group_by in groups_map:
query += ", sub_label"
query += " order by label asc"
try:
response = requests.get(f"http://{DB_HOST}:9000/exec", params={'query': query}, auth=DB_AUTH)
if response.status_code == 200: