updated dashboard
All checks were successful
Deployment / deploy-docker (push) Successful in 20s

This commit is contained in:
Melchior Reimers
2026-01-27 10:19:25 +01:00
parent e71d1a061e
commit 938c345240
2 changed files with 237 additions and 129 deletions

View File

@@ -70,9 +70,39 @@
</select> </select>
</div> </div>
<div class="glass p-8 mb-8"> <div class="mb-8">
<h3 class="text-lg font-bold mb-6 text-slate-300">Moving Average: Tradezahlen & Volumen (alle Exchanges)</h3> <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> <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>
</div> </div>
<div class="glass p-8 mb-8"> <div class="glass p-8 mb-8">
@@ -242,153 +272,168 @@
return; 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 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 countIdx = columns.findIndex(c => c.name === 'trade_count');
const volumeIdx = columns.findIndex(c => c.name === 'volume'); const volumeIdx = columns.findIndex(c => c.name === 'volume');
// Alle Daten nach Datum aggregieren (über alle Exchanges summieren) // Alle Exchanges mit Canvas-IDs definieren
const dates = [...new Set(data.map(r => r[dateIdx]))].sort(); 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) // Alle Daten nach Datum sortieren
const dailyTotals = {}; const dates = [...new Set(data.map(r => r[dateIdx]))].sort();
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)
};
});
// Moving Average berechnen (7-Tage gleitender Durchschnitt) // Moving Average berechnen (7-Tage gleitender Durchschnitt)
const maWindow = 7; const maWindow = 7;
const tradeCountData = dates.map(d => dailyTotals[d].tradeCount);
const volumeData = dates.map(d => dailyTotals[d].volume);
const calculateMA = (data, window) => { const calculateMA = (dataArray, window) => {
return data.map((val, idx) => { return dataArray.map((val, idx) => {
if (idx < window - 1) return null; 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; return slice.reduce((a, b) => a + b, 0) / window;
}); });
}; };
const maTradeCount = calculateMA(tradeCountData, maWindow); // Für jede Exchange-Gruppe einen separaten Chart erstellen
const maVolume = calculateMA(volumeData, maWindow); Object.entries(exchangeGroups).forEach(([groupName, config]) => {
const canvas = document.getElementById(config.canvasId);
const datasets = [ if (!canvas) {
{ console.error(`Canvas element ${config.canvasId} not found`);
label: 'Trades (täglich)', return;
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
} }
];
// Zerstöre existierenden Chart
charts.movingAverage = new Chart(ctx, { if (charts[config.canvasId]) charts[config.canvasId].destroy();
type: 'line',
data: { const ctx = canvas.getContext('2d');
labels: dates.map(d => new Date(d).toLocaleDateString()),
datasets: datasets // Aggregiere Daten für diese Gruppe
}, const groupData = {};
options: { dates.forEach(date => {
responsive: true, const dayRows = data.filter(r => {
maintainAspectRatio: false, const exchange = r[exchangeIdx];
interaction: { mode: 'index', intersect: false }, return r[dateIdx] === date && config.exchanges.includes(exchange);
scales: { });
y: { groupData[date] = {
type: 'linear', tradeCount: dayRows.reduce((sum, r) => sum + (r[countIdx] || 0), 0),
display: true, volume: dayRows.reduce((sum, r) => sum + (r[volumeIdx] || 0), 0)
position: 'left', };
title: { display: true, text: 'Anzahl Trades', color: '#94a3b8' }, });
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#64748b' } const tradeData = dates.map(d => groupData[d]?.tradeCount || 0);
}, const volumeData = dates.map(d => groupData[d]?.volume || 0);
y1: { const maTradeData = calculateMA(tradeData, maWindow);
type: 'linear', const maVolumeData = calculateMA(volumeData, maWindow);
display: true,
position: 'right', charts[config.canvasId] = new Chart(ctx, {
title: { display: true, text: 'Volumen (€)', color: '#94a3b8' }, type: 'line',
grid: { drawOnChartArea: false }, data: {
ticks: { labels: dates.map(d => new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })),
color: '#64748b', datasets: [
callback: function(value) { {
if (value >= 1e6) return (value / 1e6).toFixed(1) + 'M'; label: 'Trades (MA)',
if (value >= 1e3) return (value / 1e3).toFixed(0) + 'k'; data: maTradeData,
return value; 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: { plugins: {
grid: { display: false }, legend: {
ticks: { color: '#64748b', maxRotation: 45 } display: true,
} position: 'top',
}, labels: { color: '#94a3b8', boxWidth: 10, usePointStyle: true, padding: 8, font: { size: 10 } }
plugins: { },
legend: { tooltip: {
display: true, callbacks: {
position: 'bottom', label: function(context) {
labels: { color: '#94a3b8', boxWidth: 12, usePointStyle: true, padding: 15 } let label = context.dataset.label || '';
}, if (label) label += ': ';
tooltip: { if (context.parsed.y !== null) {
callbacks: { if (context.dataset.yAxisID === 'y1') {
label: function(context) { label += '€' + context.parsed.y.toLocaleString();
let label = context.dataset.label || ''; } else {
if (label) label += ': '; label += context.parsed.y.toLocaleString();
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) { } catch (err) {
console.error('Error loading moving average:', err); console.error('Error loading moving average:', err);

View File

@@ -348,9 +348,9 @@ async def get_volume_changes(days: int = 7):
if days not in [7, 30, 42, 69, 180, 365]: 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") 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""" query = f"""
select select
timestamp as date,
exchange, exchange,
trade_count, trade_count,
volume, volume,
@@ -359,11 +359,74 @@ async def get_volume_changes(days: int = 7):
trend trend
from analytics_volume_changes from analytics_volume_changes
where period_days = {days} where period_days = {days}
and timestamp >= dateadd('d', -{days}, now()) order by timestamp desc
order by date desc, exchange asc limit 20
""" """
data = query_questdb(query, timeout=5) 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) return format_questdb_response(data)
@app.get("/api/statistics/stock-trends") @app.get("/api/statistics/stock-trends")