diff --git a/Dockerfile.analytics b/Dockerfile.analytics
new file mode 100644
index 0000000..e35770f
--- /dev/null
+++ b/Dockerfile.analytics
@@ -0,0 +1,10 @@
+FROM python:3.12-slim
+
+WORKDIR /app
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["python", "-m", "src.analytics.worker"]
diff --git a/daemon.py b/daemon.py
index 5a1d48e..edf77aa 100644
--- a/daemon.py
+++ b/daemon.py
@@ -6,7 +6,6 @@ import requests
from src.exchanges.eix import EIXExchange
from src.exchanges.ls import LSExchange
from src.database.questdb_client import DatabaseClient
-from src.analytics.worker import AnalyticsWorker
logging.basicConfig(
level=logging.INFO,
@@ -28,6 +27,8 @@ def get_last_trade_timestamp(db_url, exchange_name):
data = response.json()
if data['dataset']:
# QuestDB returns timestamp in micros since epoch by default in some views, or ISO
+ # Let's assume the timestamp is in the dataset
+ # ILP timestamps are stored as designated timestamps.
ts_value = data['dataset'][0][0] # Adjust index based on column order
if isinstance(ts_value, str):
return datetime.datetime.fromisoformat(ts_value.replace('Z', '+00:00'))
@@ -44,8 +45,14 @@ def run_task(historical=False):
eix = EIXExchange()
ls = LSExchange()
+ # Pass last_ts to fetcher to allow smart filtering
+ # daemon.py runs daily, so we want to fetch everything since DB state
+ # BUT we need to be careful: eix.py's fetch_latest_trades needs 'since_date' argument
+ # We can't pass it here directly in the tuple easily because last_ts is calculated inside the loop.
+
+ # We will modify the loop below to handle args dynamically
exchanges_to_process = [
- (eix, {'limit': None if historical else 5}),
+ (eix, {'limit': None if historical else 5}), # Default limit 5 for safety if no historical
(ls, {'include_yesterday': historical})
]
@@ -84,14 +91,6 @@ def run_task(historical=False):
except Exception as e:
logger.error(f"Error processing exchange {exchange.name}: {e}")
-def run_analytics(db_url="questdb", db_port=9000):
- try:
- worker = AnalyticsWorker(db_host=db_url, db_port=db_port, auth=DB_AUTH)
- worker.initialize_tables()
- worker.run_aggregation()
- except Exception as e:
- logger.error(f"Analytics aggregation failed: {e}")
-
def main():
logger.info("Trading Daemon started.")
@@ -112,12 +111,10 @@ def main():
if is_empty:
logger.info("Database is empty or table doesn't exist. Triggering initial historical fetch...")
run_task(historical=True)
- run_analytics()
else:
logger.info("Found existing data in database. Triggering catch-up sync...")
# Run a normal task to fetch any missing data since the last run
run_task(historical=False)
- run_analytics()
logger.info("Catch-up sync completed. Waiting for scheduled run at 23:00.")
while True:
@@ -125,7 +122,6 @@ def main():
# Täglich um 23:00 Uhr
if now.hour == 23 and now.minute == 0:
run_task(historical=False)
- run_analytics()
# Warte 61s, um Mehrfachausführung in derselben Minute zu verhindern
time.sleep(61)
diff --git a/dashboard/public/index.html b/dashboard/public/index.html
index 66cc127..e987b36 100644
--- a/dashboard/public/index.html
+++ b/dashboard/public/index.html
@@ -251,6 +251,40 @@
+
+
+
+
+
Erweiterte Statistiken
+
+ 7 Tage
+ 30 Tage
+ 42 Tage
+ 69 Tage
+ 180 Tage
+ 365 Tage
+
+
+
+
+
+
Moving Average: Tradezahlen & Volumen je Exchange
+
+
+
+
+
+
Tradingvolumen & Anzahl Änderungen
+
+
+
+
+
+
Trendanalyse: Häufig gehandelte Aktien
+
+
+
+
@@ -268,10 +302,6 @@
Today
Last 7 Days
Last 30 Days
- Last 42 Days
- Last 69 Days
- Last 6 Months (180d)
- Last Year (365d)
Year to Date (YTD)
Full Year 2026
Custom Range...
@@ -534,6 +564,10 @@
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(); }
@@ -589,36 +623,18 @@
if (y === 'all') {
// Dual axis for breakdown
// Volume Dataset
- const volData = labels.map(l => {
- const row = data.find(r => r[0] === l && r[1] === name);
- return row ? row[3] : 0; // value_volume is index 3
- });
-
datasets.push({
label: `${name} (Vol)`,
- data: volData,
+ 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'
});
-
- // Add MA7 for Volume if enough data points
- if (volData.length > 7) {
- const ma7 = calculateMA(volData, 7);
- datasets.push({
- label: `${name} (Vol MA7)`,
- data: ma7,
- borderColor: `hsla(${hue}, 90%, 80%, 0.8)`,
- borderWidth: 1.5,
- borderDash: [5, 5],
- pointRadius: 0,
- yAxisID: 'y',
- type: 'line',
- tension: 0.4
- });
- }
// Count Dataset
datasets.push({
label: `${name} (Cnt)`,
@@ -886,22 +902,6 @@
updateUrlParams();
}
- function calculateMA(data, period) {
- let ma = [];
- for (let i = 0; i < data.length; i++) {
- if (i < period - 1) {
- ma.push(null);
- continue;
- }
- let sum = 0;
- for (let j = 0; j < period; j++) {
- sum += data[i - j] || 0;
- }
- ma.push(sum / period);
- }
- return ma;
- }
-
function fillMetadataTable() {
const tbody = document.getElementById('metadataRows');
tbody.innerHTML = store.metadata.map(r => `
@@ -931,7 +931,355 @@
rows.forEach(r => r.style.display = r.innerText.toLowerCase().includes(q.toLowerCase()) ? '' : 'none');
}
- window.onload = async () => { await fetchData(); syncStateFromUrl(); setInterval(fetchData, 30000); };
+ 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 = `
+
+
+
+ ISIN
+ Trades
+ Volumen (€)
+ Anzahl Δ (%)
+ Volumen Δ (%)
+
+
+
+ ${sorted.map(r => `
+
+ ${r[isinIdx]}
+ ${(r[countIdx] || 0).toLocaleString()}
+ €${((r[volumeIdx] || 0) / 1e6).toFixed(2)}M
+ ${((r[countChangeIdx] || 0)).toFixed(2)}%
+ ${((r[volumeChangeIdx] || 0)).toFixed(2)}%
+
+ `).join('')}
+
+
+ `;
+ } 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);
+ };