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

This commit is contained in:
Melchior Reimers
2026-01-25 18:24:36 +01:00
parent 9185ec3ef6
commit 3c9d277a4c
3 changed files with 488 additions and 22 deletions

View File

@@ -0,0 +1,86 @@
// Analytics Parameter Konfiguration
// Diese Datei definiert alle verfügbaren Parameter für Custom Analytics
// Neue Parameter können hier einfach hinzugefügt werden
const ANALYTICS_CONFIG = {
// X-Achse Optionen
xAxis: {
'date': {
label: 'Datum',
type: 'date',
description: 'Zeitraum auf der X-Achse'
},
'exchange': {
label: 'Exchange',
type: 'string',
description: 'Exchange auf der X-Achse'
},
'isin': {
label: 'ISIN',
type: 'string',
description: 'ISIN auf der X-Achse'
}
},
// Y-Achse Optionen (Metriken)
yAxis: {
'volume': {
label: 'Volumen',
unit: '€',
description: 'Handelsvolumen in Euro'
},
'trade_count': {
label: 'Tradezahlen',
unit: '',
description: 'Anzahl der Trades'
},
'avg_price': {
label: 'Durchschnittspreis',
unit: '€',
description: 'Durchschnittlicher Handelspreis'
}
},
// Gruppierungs-Optionen
groupBy: {
'exchange': {
label: 'Exchange',
description: 'Gruppierung nach Exchange'
},
'isin': {
label: 'ISIN',
description: 'Gruppierung nach ISIN'
},
'date': {
label: 'Datum',
description: 'Gruppierung nach Datum'
}
},
// Filter-Optionen
filters: {
'exchanges': {
label: 'Exchanges',
type: 'multiselect',
description: 'Filter nach Exchanges (komma-separiert)'
},
'isins': {
label: 'ISINs',
type: 'multiselect',
description: 'Filter nach ISINs (komma-separiert)'
}
}
};
// Hilfsfunktion zum Abrufen von Konfigurationswerten
function getConfig(category, key) {
return ANALYTICS_CONFIG[category]?.[key] || null;
}
// Funktion zum Hinzufügen neuer Parameter (für zukünftige Erweiterungen)
function addConfigParameter(category, key, config) {
if (!ANALYTICS_CONFIG[category]) {
ANALYTICS_CONFIG[category] = {};
}
ANALYTICS_CONFIG[category][key] = config;
}

View File

@@ -6,6 +6,7 @@
<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; }
@@ -26,6 +27,9 @@
<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>
@@ -40,11 +44,11 @@
<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">Volume (7d)</p>
<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">Total Trades</p>
<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">
@@ -83,6 +87,53 @@
</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" onchange="updateCustomGraph(); updateUrlParams()">
</div>
</div>
<button onclick="updateCustomGraph()" class="btn-primary px-6 py-2">Graph aktualisieren</button>
<button onclick="shareCustomGraph()" class="glass px-6 py-2 ml-2 text-sky-400 hover:text-sky-200">🔗 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>
@@ -92,32 +143,35 @@
async function fetchData() {
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=7`).then(r => r.json()),
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}/statistics/total-trades`).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();
updateDashboard(days);
} catch (err) {
console.error('Error fetching data:', err);
}
}
function updateDashboard() {
// Volumen aus den letzten 7 Tagen (aggregiert)
function updateDashboard(days = 7) {
// 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 (ALLE Trades)
// 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
@@ -130,10 +184,18 @@
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', 'custom-analytics': 'Custom Analytics' };
document.getElementById('pageTitle').innerText = titles[viewId] || 'Dashboard';
updateUrlParams();
}
async function loadStatistics() {
const days = document.getElementById('statisticsPeriod').value;
const days = parseInt(document.getElementById('statisticsPeriod')?.value || '7');
// Aktualisiere auch die Statistik-Karten
await fetchData();
// Lade dann die Graphen
await Promise.all([
loadMovingAverage(days),
loadVolumeChanges(days),
@@ -436,8 +498,238 @@
}
}
// 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]))];
const dates = [...new Set(data.map(r => r[xIdx]))].sort();
const colors = ['#38bdf8', '#f43f5e', '#10b981', '#fbbf24', '#8b5cf6', '#f97316', '#ec4899'];
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];
}
await fetchData();
loadFromUrlParams();
setInterval(fetchData, 30000);
setTimeout(() => loadStatistics(), 1000);
};

View File

@@ -94,17 +94,28 @@ async def get_metadata():
return format_questdb_response(data)
@app.get("/api/summary")
async def get_summary():
async def get_summary(days: int = None):
"""
Gibt Zusammenfassung zurück. Nutzt analytics_daily_summary für total_trades (alle Trades).
"""
# Hole Gesamtzahl aller Trades aus analytics_daily_summary
query = """
select
sum(total_trades) as total_trades,
sum(total_volume) as total_volume
from analytics_daily_summary
Gibt Zusammenfassung zurück. Nutzt analytics_daily_summary für total_trades.
Optional: days Parameter für Zeitraum-basierte Zusammenfassung.
"""
if days:
# Zeitraum-basierte Zusammenfassung
query = f"""
select
sum(total_trades) as total_trades,
sum(total_volume) as total_volume
from analytics_daily_summary
where timestamp >= dateadd('d', -{days}, now())
"""
else:
# Gesamtzahl aller Trades
query = """
select
sum(total_trades) as total_trades,
sum(total_volume) as total_volume
from analytics_daily_summary
"""
data = query_questdb(query)
if data and data.get('dataset') and data['dataset']:
@@ -135,15 +146,92 @@ async def get_summary():
return format_questdb_response(data)
@app.get("/api/statistics/total-trades")
async def get_total_trades():
"""Gibt Gesamtzahl aller Trades zurück (aus analytics_daily_summary)"""
query = "select sum(total_trades) as total from analytics_daily_summary"
async def get_total_trades(days: int = None):
"""Gibt Gesamtzahl aller Trades zurück (aus analytics_daily_summary). Optional: days Parameter für Zeitraum."""
if days:
query = f"select sum(total_trades) as total from analytics_daily_summary where timestamp >= dateadd('d', -{days}, now())"
else:
query = "select sum(total_trades) as total from analytics_daily_summary"
data = query_questdb(query)
if data and data.get('dataset') and data['dataset']:
total = data['dataset'][0][0] if data['dataset'][0][0] else 0
return {'total_trades': total}
return {'total_trades': 0}
@app.get("/api/custom-analytics")
async def get_custom_analytics(
date_from: str,
date_to: str,
x_axis: str = "date",
y_axis: str = "volume",
group_by: str = "exchange",
exchanges: str = None
):
"""
Flexibler Analytics-Endpunkt für custom Graphen.
Parameters:
- date_from: Startdatum (YYYY-MM-DD)
- date_to: Enddatum (YYYY-MM-DD)
- x_axis: X-Achse (date, exchange, isin)
- y_axis: Y-Achse (volume, trade_count, avg_price)
- group_by: Gruppierung (exchange, isin, date)
- exchanges: Komma-separierte Liste von Exchanges (optional)
"""
# Validiere Parameter
valid_x_axis = ["date", "exchange", "isin"]
valid_y_axis = ["volume", "trade_count", "avg_price"]
valid_group_by = ["exchange", "isin", "date"]
if x_axis not in valid_x_axis:
raise HTTPException(status_code=400, detail=f"Invalid x_axis. Must be one of: {valid_x_axis}")
if y_axis not in valid_y_axis:
raise HTTPException(status_code=400, detail=f"Invalid y_axis. Must be one of: {valid_y_axis}")
if group_by not in valid_group_by:
raise HTTPException(status_code=400, detail=f"Invalid group_by. Must be one of: {valid_group_by}")
# Baue Query auf
y_axis_map = {
"volume": "sum(price * quantity)",
"trade_count": "count(*)",
"avg_price": "avg(price)"
}
x_axis_map = {
"date": "date_trunc('day', timestamp)",
"exchange": "exchange",
"isin": "isin"
}
group_by_map = {
"exchange": "exchange",
"isin": "isin",
"date": "date_trunc('day', timestamp)"
}
y_metric = y_axis_map[y_axis]
x_label = x_axis_map[x_axis]
group_by_field = group_by_map[group_by]
query = f"""
select
{x_label} as x_value,
{group_by_field} as group_value,
{y_metric} as y_value
from trades
where timestamp >= '{date_from}'
and timestamp <= '{date_to}'
"""
if exchanges:
exchange_list = ",".join([f"'{e.strip()}'" for e in exchanges.split(",")])
query += f" and exchange in ({exchange_list})"
query += f" group by {x_label}, {group_by_field} order by {x_label} asc, {group_by_field} asc"
data = query_questdb(query, timeout=15)
return format_questdb_response(data)
@app.get("/api/statistics/moving-average")
async def get_moving_average(days: int = 7, exchange: str = None):
"""