diff --git a/dashboard/public/index.html b/dashboard/public/index.html index 0acc709..21c5236 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -70,9 +70,39 @@ -
-

Moving Average: Tradezahlen & Volumen (alle Exchanges)

-
+
+

Moving Average: Tradezahlen & Volumen je Exchange

+

Blau = Trades (7-Tage MA), Grün = Volumen (7-Tage MA)

+
+
+

EIX

+
+
+
+

LS (Lang & Schwarz)

+
+
+
+

GETTEX

+
+
+
+

XETRA

+
+
+
+

Frankfurt (FRA)

+
+
+
+

Stuttgart (STU)

+
+
+
+

Quotrix

+
+
+
@@ -242,153 +272,168 @@ return; } - const canvas = document.getElementById('movingAverageChart'); - if (!canvas) { - console.error('Canvas element movingAverageChart not found'); - return; - } - const ctx = canvas.getContext('2d'); - if (charts.movingAverage) charts.movingAverage.destroy(); - 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'); - // Alle Daten nach Datum aggregieren (über alle Exchanges summieren) - const dates = [...new Set(data.map(r => r[dateIdx]))].sort(); + // 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' } + }; - // Summiere Trade Count und Volume pro Tag (alle Exchanges zusammen) - const dailyTotals = {}; - dates.forEach(date => { - const dayRows = data.filter(r => r[dateIdx] === date); - dailyTotals[date] = { - tradeCount: dayRows.reduce((sum, r) => sum + (r[countIdx] || 0), 0), - volume: dayRows.reduce((sum, r) => sum + (r[volumeIdx] || 0), 0) - }; - }); + // Alle Daten nach Datum sortieren + const dates = [...new Set(data.map(r => r[dateIdx]))].sort(); // Moving Average berechnen (7-Tage gleitender Durchschnitt) const maWindow = 7; - const tradeCountData = dates.map(d => dailyTotals[d].tradeCount); - const volumeData = dates.map(d => dailyTotals[d].volume); - const calculateMA = (data, window) => { - return data.map((val, idx) => { + const calculateMA = (dataArray, window) => { + return dataArray.map((val, idx) => { if (idx < window - 1) return null; - const slice = data.slice(idx - window + 1, idx + 1); + const slice = dataArray.slice(idx - window + 1, idx + 1); return slice.reduce((a, b) => a + b, 0) / window; }); }; - const maTradeCount = calculateMA(tradeCountData, maWindow); - const maVolume = calculateMA(volumeData, maWindow); - - const datasets = [ - { - label: 'Trades (täglich)', - data: tradeCountData, - borderColor: '#38bdf8', - backgroundColor: '#38bdf833', - borderWidth: 1, - yAxisID: 'y', - tension: 0.3, - pointRadius: 2 - }, - { - label: `Trades (${maWindow}-Tage MA)`, - data: maTradeCount, - borderColor: '#38bdf8', - backgroundColor: 'transparent', - borderWidth: 3, - yAxisID: 'y', - tension: 0.4, - pointRadius: 0 - }, - { - label: 'Volumen (täglich)', - data: volumeData, - borderColor: '#10b981', - backgroundColor: '#10b98133', - borderWidth: 1, - yAxisID: 'y1', - tension: 0.3, - pointRadius: 2 - }, - { - label: `Volumen (${maWindow}-Tage MA)`, - data: maVolume, - borderColor: '#10b981', - backgroundColor: 'transparent', - borderWidth: 3, - yAxisID: 'y1', - tension: 0.4, - pointRadius: 0 + // 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; } - ]; - - 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: 'Anzahl Trades', color: '#94a3b8' }, - grid: { color: 'rgba(255,255,255,0.05)' }, - ticks: { color: '#64748b' } - }, - y1: { - type: 'linear', - display: true, - position: 'right', - title: { display: true, text: 'Volumen (€)', color: '#94a3b8' }, - grid: { drawOnChartArea: false }, - ticks: { - color: '#64748b', - callback: function(value) { - if (value >= 1e6) return (value / 1e6).toFixed(1) + 'M'; - if (value >= 1e3) return (value / 1e3).toFixed(0) + 'k'; - return value; + + // 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]; + return r[dateIdx] === date && config.exchanges.includes(exchange); + }); + groupData[date] = { + tradeCount: dayRows.reduce((sum, r) => sum + (r[countIdx] || 0), 0), + volume: dayRows.reduce((sum, r) => sum + (r[volumeIdx] || 0), 0) + }; + }); + + const tradeData = dates.map(d => groupData[d]?.tradeCount || 0); + const volumeData = dates.map(d => groupData[d]?.volume || 0); + 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 } } }, - x: { - grid: { display: false }, - ticks: { color: '#64748b', maxRotation: 45 } - } - }, - plugins: { - legend: { - display: true, - position: 'bottom', - labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 15 } - }, - 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(); + 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; } - return label; } } } } - } + }); }); } catch (err) { console.error('Error loading moving average:', err); diff --git a/dashboard/server.py b/dashboard/server.py index c1c8abe..00ab81f 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -348,9 +348,9 @@ async def get_volume_changes(days: int = 7): if days not in [7, 30, 42, 69, 180, 365]: raise HTTPException(status_code=400, detail="Invalid days parameter. Must be one of: 7, 30, 42, 69, 180, 365") + # Hole die neuesten Daten für den angegebenen Zeitraum query = f""" select - timestamp as date, exchange, trade_count, volume, @@ -359,11 +359,74 @@ async def get_volume_changes(days: int = 7): trend from analytics_volume_changes where period_days = {days} - and timestamp >= dateadd('d', -{days}, now()) - order by date desc, exchange asc + order by timestamp desc + limit 20 """ data = query_questdb(query, timeout=5) + + # Falls keine vorberechneten Daten vorhanden, berechne on-the-fly + if not data or not data.get('dataset'): + logger.info(f"No pre-calculated volume changes found for {days} days, calculating on-the-fly") + + # Berechne Volumen-Änderungen direkt aus trades + query = f""" + with + first_half as ( + select + exchange, + count(*) as trade_count, + sum(price * quantity) as volume + from trades + where timestamp >= dateadd('d', -{days}, now()) + and timestamp < dateadd('d', -{days/2}, now()) + group by exchange + ), + second_half as ( + select + exchange, + count(*) as trade_count, + sum(price * quantity) as volume + from trades + where timestamp >= dateadd('d', -{days/2}, now()) + group by exchange + ) + select + coalesce(f.exchange, s.exchange) as exchange, + coalesce(s.trade_count, 0) as trade_count, + coalesce(s.volume, 0) as volume, + case when f.trade_count > 0 then + ((coalesce(s.trade_count, 0) - f.trade_count) * 100.0 / f.trade_count) + else 0 end as count_change_pct, + case when f.volume > 0 then + ((coalesce(s.volume, 0) - f.volume) * 100.0 / f.volume) + else 0 end as volume_change_pct, + case + when f.trade_count > 0 and f.volume > 0 then + case + when ((coalesce(s.trade_count, 0) - f.trade_count) * 100.0 / f.trade_count) > 5 + and ((coalesce(s.volume, 0) - f.volume) * 100.0 / f.volume) > 5 + then 'mehr_trades_mehr_volumen' + when ((coalesce(s.trade_count, 0) - f.trade_count) * 100.0 / f.trade_count) > 5 + and ((coalesce(s.volume, 0) - f.volume) * 100.0 / f.volume) < -5 + then 'mehr_trades_weniger_volumen' + when ((coalesce(s.trade_count, 0) - f.trade_count) * 100.0 / f.trade_count) < -5 + and ((coalesce(s.volume, 0) - f.volume) * 100.0 / f.volume) > 5 + then 'weniger_trades_mehr_volumen' + when ((coalesce(s.trade_count, 0) - f.trade_count) * 100.0 / f.trade_count) < -5 + and ((coalesce(s.volume, 0) - f.volume) * 100.0 / f.volume) < -5 + then 'weniger_trades_weniger_volumen' + else 'stabil' + end + else 'neu' + end as trend + from first_half f + full outer join second_half s on f.exchange = s.exchange + order by s.volume desc + """ + + data = query_questdb(query, timeout=15) + return format_questdb_response(data) @app.get("/api/statistics/stock-trends")