From 04a4c3f2c1d99bb13899db7b3787fba21798a463 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 22 Apr 2026 00:40:13 +0200 Subject: [PATCH] boersenchecker --- api key | 3 + modules/boersenchecker/bootstrap.php | 402 +++++++++ modules/boersenchecker/module.json | 33 +- modules/boersenchecker/pages/index.php | 27 +- modules/boersenchecker/partials/dashboard.php | 388 +++++++++ .../src/Support/DashboardPage.php | 793 ++++++++++++++++++ 6 files changed, 1617 insertions(+), 29 deletions(-) create mode 100644 api key create mode 100644 modules/boersenchecker/bootstrap.php create mode 100644 modules/boersenchecker/partials/dashboard.php create mode 100644 modules/boersenchecker/src/Support/DashboardPage.php diff --git a/api key b/api key new file mode 100644 index 0000000..8142069 --- /dev/null +++ b/api key @@ -0,0 +1,3 @@ +API Key Alphavantage.co + +NEMNJQYA35TJ1W9R diff --git a/modules/boersenchecker/bootstrap.php b/modules/boersenchecker/bootstrap.php new file mode 100644 index 0000000..f6e6a9c --- /dev/null +++ b/modules/boersenchecker/bootstrap.php @@ -0,0 +1,402 @@ +registerFunction($moduleName, 'table', static function (string $name): string { + $prefix = 'boersencheck_'; + $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return $prefix . $sanitized; +}); + +$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO { + $settings = modules()->settings($moduleName); + $useSeparate = !empty($settings['use_separate_db']); + + if ($useSeparate) { + $module = modules()->get($moduleName); + $fallback = $module['db_defaults'] ?? []; + return modules()->modulePdo($moduleName, $fallback); + } + + $base = app()->basePdo(); + if ($base instanceof \PDO) { + return $base; + } + + throw new ModuleConfigException( + $moduleName, + 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.' + ); +}); + +$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void { + $pdo = module_fn($moduleName, 'pdo'); + $table = static fn (string $name): string => module_fn($moduleName, 'table', $name); + $driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + + $portfolioTable = $table('portfolios'); + $instrumentTable = $table('instruments'); + $positionTable = $table('positions'); + $quoteTable = $table('quotes'); + + if ($driver === 'pgsql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id SERIAL PRIMARY KEY, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity NUMERIC(20,6) NOT NULL, + purchase_price NUMERIC(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees NUMERIC(20,8) NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id SERIAL PRIMARY KEY, + instrument_id INTEGER NOT NULL, + price NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at TIMESTAMP NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } elseif ($driver === 'mysql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$portfolioTable}_owner_idx (owner_sub) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY {$instrumentTable}_isin_uniq (isin), + KEY {$instrumentTable}_symbol_idx (symbol) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$positionTable}_owner_idx (owner_sub), + KEY {$positionTable}_portfolio_idx (portfolio_id), + KEY {$positionTable}_instrument_idx (instrument_id) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at) + )"); + } else { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } +}); + +$mm->registerFunction($moduleName, 'fx_service', static function (): ?object { + if (!is_dir(dirname(__DIR__) . '/mining-checker')) { + return null; + } + + try { + $config = \Modules\MiningChecker\Infrastructure\ModuleConfig::load(dirname(__DIR__) . '/mining-checker'); + $repo = new \Modules\MiningChecker\Infrastructure\MiningRepository( + \Modules\MiningChecker\Infrastructure\ConnectionFactory::make($config), + $config->tablePrefix() + ); + $fx = $config->fx(); + + return new \Modules\MiningChecker\Domain\FxService( + $repo, + (string) ($fx['url'] ?? 'https://currencyapi.net'), + (string) ($fx['currencies_url'] ?? ($fx['url'] ?? 'https://currencyapi.net')), + (int) ($fx['timeout'] ?? 10), + (int) ($fx['cache_ttl'] ?? 21600), + false, + (string) ($fx['provider'] ?? 'currencyapi'), + (string) ($fx['api_key'] ?? '') + ); + } catch (\Throwable) { + return null; + } +}); + +$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Mining-Checker FX-Service ist aktuell nicht verfuegbar.', + ]; + } + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR'); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) + ? 'Vorhandene FX-Daten weiterverwendet.' + : 'FX-Daten aus dem Mining-Checker aktualisiert.', + 'result' => $result, + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote', static function (string $symbol): array { + $settings = modules()->settings('boersenchecker'); + $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); + $timeout = (int) ($settings['alpha_vantage_timeout_sec'] ?? 12); + $timeout = $timeout > 0 ? $timeout : 12; + $symbol = strtoupper(trim($symbol)); + + if ($symbol === '') { + return [ + 'ok' => false, + 'message' => 'Kein API-Symbol hinterlegt.', + ]; + } + + if ($apiKey === '') { + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + ]; + } + + $url = 'https://www.alphavantage.co/query?' . http_build_query([ + 'function' => 'GLOBAL_QUOTE', + 'symbol' => $symbol, + 'apikey' => $apiKey, + ]); + + $responseBody = null; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch !== false) { + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => min(5, $timeout), + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $responseBody = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if (!is_string($responseBody) || $responseBody === '') { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage Anfrage fehlgeschlagen.' + . ($curlError !== '' ? ' ' . $curlError : '') + . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), + ]; + } + } + } + + if (!is_string($responseBody) || $responseBody === '') { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeout, + 'header' => "Accept: application/json\r\n", + ], + ]); + $responseBody = @file_get_contents($url, false, $context); + if (!is_string($responseBody) || $responseBody === '') { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage Anfrage lieferte keine Daten.', + ]; + } + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage Antwort ist kein gueltiges JSON.', + ]; + } + + if (!empty($decoded['Note'])) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage Limit-Hinweis: ' . trim((string) $decoded['Note']), + ]; + } + + if (!empty($decoded['Information'])) { + return [ + 'ok' => false, + 'message' => trim((string) $decoded['Information']), + ]; + } + + if (!empty($decoded['Error Message'])) { + return [ + 'ok' => false, + 'message' => trim((string) $decoded['Error Message']), + ]; + } + + $quote = is_array($decoded['Global Quote'] ?? null) ? $decoded['Global Quote'] : []; + $price = $quote['05. price'] ?? null; + if (!is_numeric($price)) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', + ]; + } + + return [ + 'ok' => true, + 'symbol' => (string) ($quote['01. symbol'] ?? $symbol), + 'price' => (float) $price, + 'latest_trading_day' => (string) ($quote['07. latest trading day'] ?? ''), + 'previous_close' => is_numeric($quote['08. previous close'] ?? null) ? (float) $quote['08. previous close'] : null, + 'change' => is_numeric($quote['09. change'] ?? null) ? (float) $quote['09. change'] : null, + 'change_percent' => (string) ($quote['10. change percent'] ?? ''), + 'fetched_at' => date('Y-m-d H:i:s'), + 'source' => 'alpha_vantage:GLOBAL_QUOTE', + 'raw' => $quote, + ]; +}); diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index c4eb0f8..74b7802 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -1,21 +1,44 @@ { "title": "Börsenchecker", - "version": "0.1.0", - "description": "Grundgeruest fuer ein Nexus-Modul zur Beobachtung und Auswertung von Boersenwerten.", + "version": "0.2.0", + "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", "enabled_by_default": false, "menu": [ - { "label": "Übersicht", "href": "/module/boersenchecker" } + { "label": "Uebersicht", "href": "/module/boersenchecker" } ], "sidebar": { "enabled": true, "collapsible": true, "default": "collapsed", "items": [ - { "label": "Übersicht", "href": "/module/boersenchecker" } + { "label": "Uebersicht", "href": "/module/boersenchecker" } ] }, "setup": { - "fields": [] + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." }, + { "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber den Mining-Checker genutzt." }, + { "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe ueber GLOBAL_QUOTE." }, + { "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, + { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." } + ] + }, + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" }, "auth": { "required": true, diff --git a/modules/boersenchecker/pages/index.php b/modules/boersenchecker/pages/index.php index f2c6bce..bee9e89 100644 --- a/modules/boersenchecker/pages/index.php +++ b/modules/boersenchecker/pages/index.php @@ -1,28 +1,7 @@ -
-
Börsenchecker
-

Börsenchecker

-

- Das Modul ist im Nexus registriert und kann jetzt schrittweise um Datenquellen, - Watchlists, Kennzahlen und Benachrichtigungen erweitert werden. -

-
- Status -
- Aktuell ist dies das initiale Grundgeruest des Moduls. -
-
+require_auth(); -
- Nächste sinnvolle Ausbaustufen -
    -
  • Watchlist fuer Ticker und ISINs
  • -
  • Kursdaten per API einlesen
  • -
  • Performance, Tagesdelta und historische Trends anzeigen
  • -
  • Alerts fuer Schwellwerte definieren
  • -
-
-
+$page = new \Modules\Boersenchecker\Support\DashboardPage(); +module_tpl('boersenchecker', 'dashboard', $page->handle()); diff --git a/modules/boersenchecker/partials/dashboard.php b/modules/boersenchecker/partials/dashboard.php new file mode 100644 index 0000000..e92435f --- /dev/null +++ b/modules/boersenchecker/partials/dashboard.php @@ -0,0 +1,388 @@ +
+
Boersenchecker
+

Depotverwaltung

+

+ Depots, Positionen und manuelle Kursverlaeufe. Die Waehrungsumrechnung nutzt, sofern verfuegbar, + die bestehende FX-Logik des Mining-Checkers weiter. +

+ + +
+ +
+ +
+ +
+ + +
+
+ +
+ + + + + +
+ + + Abbrechen + +
+
+
+ +
+ API / FX +

+ Die Umrechnung liest gespeicherte FX-Daten aus dem Mining-Checker. Eine Aktualisierung wird nur manuell + angestossen und respektiert die dortige Max-Age-Logik. +

+

+ Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten API-Symbols / Tickers pro Aktie abgerufen. +

+
+
+ + +
+
+ + +
+
+
+ Alpha Vantage Mindestabstand: Min. +
+
+ API-Key und Timeout werden ueber Modul-Setup gepflegt. +
+
+ Standard-Berichtswahrung: · Max. Alter: h +
+
+
+ +
+ + +
Bitte zuerst ein Depot anlegen.
+ +
+ + + + +
+ + + + + + +
+
+ + + + + +
+ +
+ + + Abbrechen + +
+
+ +
+ +
+ Manuellen Kurs erfassen + +
Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.
+ +
+ +
+ + + + + +
+
+ +
+
+ +
+ +
+ +
+
Noch keine Depots vorhanden.
+
+ + + 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false]; + ?> +
+
+
+ +
· Position(en)
+
+
+ Bearbeiten +
+ + + +
+
+
+ +
+ +
+
+
Investiert
+ +
+
+
Aktuell
+ +
+
+
Gewinn / Verlust
+ +
+
+
+ + +
+ +
+ Positionen + +
Noch keine Positionen vorhanden.
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepotAktieISIN / WKNStueckKaufLetzter KursWertDeltaAktion
+ + +
+ +
+ +
+
+ +
+
+ + +
+ + kein Kurs + +
+ + + + n/a + + + + + + n/a + + +
+ Bearbeiten + Kurs erfassen +
+ + + +
+
+ + + +
+
+
+
+ +
+ +
+ Kursverlauf + +
Noch keine Kursdaten vorhanden.
+ + $instrument): ?> + +
+
+
+ +
+ · +
+
+ Neuen Kurs erfassen +
+ +
Noch keine historischen Kurse vorhanden.
+ +
+ + + + + + + + + + + + + + + + + + + +
ZeitpunktKursQuelleAktion
+
+ + + +
+
+
+ +
+ + +
+
diff --git a/modules/boersenchecker/src/Support/DashboardPage.php b/modules/boersenchecker/src/Support/DashboardPage.php new file mode 100644 index 0000000..d7ece47 --- /dev/null +++ b/modules/boersenchecker/src/Support/DashboardPage.php @@ -0,0 +1,793 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + + $this->user = \auth_user() ?? []; + $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local')); + $this->moduleSettings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR')); + $this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + + $this->alphaMinIntervalMinutes = (int) ($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? 60); + if ($this->alphaMinIntervalMinutes <= 0) { + $this->alphaMinIntervalMinutes = 60; + } + + $this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0); + $this->editPositionId = (int) ($_GET['edit_position'] ?? 0); + + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->portfolioTable = $table('portfolios'); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + } + + public function handle(): array + { + $notice = null; + $error = null; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $notice = $this->handlePost(); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $state = $this->loadState(); + + if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) { + $notice = $state['notice_override']; + } + if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) { + $error = $state['error_override']; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'fxMaxAgeHours' => $this->fxMaxAgeHours, + 'alphaMinIntervalMinutes' => $this->alphaMinIntervalMinutes, + 'editPortfolio' => $state['editPortfolio'], + 'editPosition' => $state['editPosition'], + 'portfolios' => $state['portfolios'], + 'portfolioById' => $state['portfolioById'], + 'portfolioStats' => $state['portfolioStats'], + 'positions' => $state['positions'], + 'instrumentList' => $state['instrumentList'], + 'quoteHistory' => $state['quoteHistory'], + 'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'], + 'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'], + 'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale), + ]; + } + + private function handlePost(): string + { + $action = trim((string) ($_POST['action'] ?? '')); + + return match ($action) { + 'save_portfolio' => $this->savePortfolio(), + 'delete_portfolio' => $this->deletePortfolio(), + 'save_position' => $this->savePosition(), + 'delete_position' => $this->deletePosition(), + 'save_quote' => $this->saveQuote(), + 'refresh_alpha_vantage_position' => $this->refreshAlphaVantagePosition(), + 'refresh_alpha_vantage_all' => $this->refreshAlphaVantageAll(), + 'delete_quote' => $this->deleteQuote(), + 'refresh_fx' => $this->refreshFx(), + default => '', + }; + } + + private function loadState(): array + { + $portfolios = $this->fetchPortfolios(); + $positions = $this->fetchPositions(); + $instrumentList = $this->buildInstrumentList($positions); + [$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList)); + $portfolioById = $this->buildPortfolioById($portfolios); + [$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes); + + $editPortfolio = null; + if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) { + $editPortfolio = $portfolioById[$this->editPortfolioId]; + } + + $editPosition = null; + if ($this->editPositionId > 0) { + foreach ($positions as $position) { + if ((int) $position['id'] === $this->editPositionId) { + $editPosition = $position; + break; + } + } + } + + $selectedInstrumentForQuote = $editPosition + ? (int) $editPosition['instrument_id'] + : (int) ($_GET['instrument_id'] ?? 0); + $selectedInstrumentQuoteCurrency = $this->defaultReportCurrency; + if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) { + $selectedInstrumentQuoteCurrency = $this->normalizeCurrency( + (string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency) + ); + } + + return [ + 'portfolios' => $portfolios, + 'portfolioById' => $portfolioById, + 'portfolioStats' => $portfolioStats, + 'positions' => $positions, + 'instrumentList' => $instrumentList, + 'quoteHistory' => $quoteHistory, + 'editPortfolio' => $editPortfolio, + 'editPosition' => $editPosition, + 'selectedInstrumentForQuote' => $selectedInstrumentForQuote, + 'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency, + ]; + } + + private function savePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $name = trim((string) ($_POST['portfolio_name'] ?? '')); + $baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency)); + $notes = trim((string) ($_POST['portfolio_notes'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Bitte einen Depotnamen angeben.'); + } + + if ($portfolioId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->portfolioTable . ' + SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes) + VALUES (:owner_sub, :name, :base_currency, :notes)' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot angelegt.'; + } + + private function deletePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub'); + $countStmt->execute([ + 'portfolio_id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ((int) $countStmt->fetchColumn() > 0) { + throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.'); + } + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Depot geloescht.'; + } + + private function savePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $quantity = (float) ($_POST['quantity'] ?? 0); + $purchasePrice = (float) ($_POST['purchase_price'] ?? 0); + $purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency)); + $purchaseDate = trim((string) ($_POST['purchase_date'] ?? '')); + $fees = trim((string) ($_POST['fees'] ?? '')); + $notes = trim((string) ($_POST['position_notes'] ?? '')); + + if ($portfolioId <= 0) { + throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); + } + if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') { + throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.'); + } + + $portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1'); + $portfolioOwnerStmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ($portfolioOwnerStmt->fetchColumn() === false) { + throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.'); + } + + $instrumentId = $this->upsertInstrument([ + 'id' => (int) ($_POST['instrument_id'] ?? 0), + 'isin' => $_POST['isin'] ?? '', + 'wkn' => $_POST['wkn'] ?? '', + 'symbol' => $_POST['symbol'] ?? '', + 'name' => $_POST['instrument_name'] ?? '', + 'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency, + 'market' => $_POST['market'] ?? '', + ]); + + $payload = [ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + 'instrument_id' => $instrumentId, + 'quantity' => $quantity, + 'purchase_price' => $purchasePrice, + 'purchase_currency' => $purchaseCurrency, + 'purchase_date' => $purchaseDate, + 'fees' => $fees !== '' ? (float) $fees : null, + 'notes' => $notes !== '' ? $notes : null, + ]; + + if ($positionId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->positionTable . ' + SET portfolio_id = :portfolio_id, + instrument_id = :instrument_id, + quantity = :quantity, + purchase_price = :purchase_price, + purchase_currency = :purchase_currency, + purchase_date = :purchase_date, + fees = :fees, + notes = :notes, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute($payload + ['id' => $positionId]); + return 'Position aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->positionTable . ' ( + owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes + ) VALUES ( + :owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes + )' + ); + $stmt->execute($payload); + return 'Position gespeichert.'; + } + + private function deletePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Position geloescht.'; + } + + private function saveQuote(): string + { + $instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0); + $price = (float) ($_POST['quote_price'] ?? 0); + $currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency)); + $quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')); + $source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual'; + + if ($instrumentId <= 0 || $price <= 0) { + throw new RuntimeException('Bitte Aktie und Kurs angeben.'); + } + + $this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source); + return 'Kurs gespeichert.'; + } + + private function refreshAlphaVantagePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'SELECT + p.instrument_id, + i.name AS instrument_name, + i.symbol, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' + ); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + throw new RuntimeException('Position nicht gefunden.'); + } + + $instrumentId = (int) $row['instrument_id']; + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist noch kein API-Symbol / Ticker hinterlegt.'); + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->alphaMinIntervalMinutes * 60)) { + return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + + $this->storeQuote( + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) $apiResult['source'] + ); + return 'API-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; + } + + private function refreshAlphaVantageAll(): string + { + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY i.name ASC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.'); + } + + $fetched = 0; + $reused = 0; + $skipped = 0; + $failed = 0; + $errors = []; + + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + + if ($instrumentId <= 0 || $symbol === '') { + $skipped++; + continue; + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->alphaMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote', $symbol); + if (empty($apiResult['ok'])) { + $failed++; + $errors[] = (string) ($row['name'] ?? $symbol) . ': ' . (string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'); + if (stripos((string) ($apiResult['message'] ?? ''), 'limit') !== false) { + break; + } + continue; + } + + $this->storeQuote( + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) $apiResult['source'] + ); + $fetched++; + } + + if ($errors !== []) { + throw new RuntimeException( + 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. ' + . implode(' | ', array_slice($errors, 0, 3)) + ); + } + + return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.'; + } + + private function deleteQuote(): string + { + $quoteId = (int) ($_POST['quote_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->quoteTable . ' + WHERE id = :id + AND instrument_id IN ( + SELECT DISTINCT instrument_id + FROM ' . $this->positionTable . ' + WHERE owner_sub = :owner_sub + )' + ); + $stmt->execute([ + 'id' => $quoteId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Kurseintrag geloescht.'; + } + + private function refreshFx(): string + { + $result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours); + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + return (string) ($result['message'] ?? 'FX-Daten aktualisiert.'); + } + + private function fetchPortfolios(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC'); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchPositions(): array + { + $stmt = $this->pdo->prepare( + 'SELECT + p.*, + i.isin, + i.wkn, + i.symbol, + i.name AS instrument_name, + i.quote_currency, + i.market + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY p.purchase_date DESC, p.id DESC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function buildInstrumentList(array $positions): array + { + $instrumentList = []; + foreach ($positions as $position) { + $instrumentId = (int) $position['instrument_id']; + if (!isset($instrumentList[$instrumentId])) { + $instrumentList[$instrumentId] = [ + 'id' => $instrumentId, + 'name' => (string) $position['instrument_name'], + 'symbol' => (string) ($position['symbol'] ?? ''), + 'isin' => (string) ($position['isin'] ?? ''), + 'quote_currency' => (string) ($position['quote_currency'] ?? ''), + ]; + } + } + return $instrumentList; + } + + private function fetchQuotes(array $instrumentIds): array + { + $quoteHistory = []; + $latestQuotes = []; + if ($instrumentIds === []) { + return [$quoteHistory, $latestQuotes]; + } + + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $stmt->execute($instrumentIds); + $quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($quotes as $quote) { + $instrumentId = (int) $quote['instrument_id']; + $quoteHistory[$instrumentId][] = $quote; + if (!isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $quote; + } + } + + return [$quoteHistory, $latestQuotes]; + } + + private function buildPortfolioById(array $portfolios): array + { + $portfolioById = []; + foreach ($portfolios as $portfolio) { + $portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency)); + $portfolioById[(int) $portfolio['id']] = $portfolio; + } + return $portfolioById; + } + + private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array + { + $portfolioStats = []; + foreach ($portfolioById as $portfolioId => $portfolio) { + $portfolioStats[$portfolioId] = [ + 'invested' => 0.0, + 'current' => 0.0, + 'gain' => 0.0, + 'positions' => 0, + 'has_invested' => false, + 'has_current' => false, + ]; + } + + foreach ($positions as &$position) { + $portfolioId = (int) $position['portfolio_id']; + $baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency); + $quantity = (float) $position['quantity']; + $purchasePrice = (float) $position['purchase_price']; + $fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0; + $purchaseTotal = ($quantity * $purchasePrice) + $fees; + $purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency); + $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; + $currentTotalBase = null; + + if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) { + $currentOriginal = $quantity * (float) $latestQuote['price']; + $currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency); + $position['latest_price'] = (float) $latestQuote['price']; + $position['latest_currency'] = (string) $latestQuote['currency']; + $position['latest_quoted_at'] = (string) $latestQuote['quoted_at']; + $position['current_total_base'] = $currentTotalBase; + } else { + $position['latest_price'] = null; + $position['latest_currency'] = null; + $position['latest_quoted_at'] = null; + $position['current_total_base'] = null; + } + + $position['purchase_total'] = $purchaseTotal; + $position['purchase_total_base'] = $purchaseTotalBase; + $position['base_currency'] = $baseCurrency; + $position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null + ? $currentTotalBase - $purchaseTotalBase + : null; + + if (isset($portfolioStats[$portfolioId])) { + $portfolioStats[$portfolioId]['positions']++; + if ($purchaseTotalBase !== null) { + $portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase; + $portfolioStats[$portfolioId]['has_invested'] = true; + } + if ($currentTotalBase !== null) { + $portfolioStats[$portfolioId]['current'] += $currentTotalBase; + $portfolioStats[$portfolioId]['has_current'] = true; + } + } + } + unset($position); + + foreach ($portfolioStats as &$stats) { + $stats['gain'] = ($stats['has_invested'] && $stats['has_current']) + ? $stats['current'] - $stats['invested'] + : null; + } + unset($stats); + + return [$positions, $portfolioStats]; + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alpha_vantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function upsertInstrument(array $payload): int + { + $instrumentId = (int) ($payload['id'] ?? 0); + $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $data = [ + 'isin' => trim((string) ($payload['isin'] ?? '')) ?: null, + 'wkn' => trim((string) ($payload['wkn'] ?? '')) ?: null, + 'symbol' => trim((string) ($payload['symbol'] ?? '')) ?: null, + 'name' => trim((string) ($payload['name'] ?? '')), + 'quote_currency' => $this->normalizeCurrency((string) ($payload['quote_currency'] ?? 'EUR')), + 'market' => trim((string) ($payload['market'] ?? '')) ?: null, + ]; + + if ($data['name'] === '') { + throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.'); + } + + if ($instrumentId <= 0) { + $instrumentId = $this->findInstrumentId($payload) ?? 0; + } + + if ($instrumentId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->instrumentTable . ' + SET isin = :isin, wkn = :wkn, symbol = :symbol, name = :name, quote_currency = :quote_currency, market = :market, updated_at = CURRENT_TIMESTAMP + WHERE id = :id' + ); + $stmt->execute($data + ['id' => $instrumentId]); + return $instrumentId; + } + + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market) + RETURNING id' + ); + $stmt->execute($data); + return (int) $stmt->fetchColumn(); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)' + ); + $stmt->execute($data); + return (int) $this->pdo->lastInsertId(); + } + + private function findInstrumentId(array $payload): ?int + { + $isin = trim((string) ($payload['isin'] ?? '')); + $symbol = trim((string) ($payload['symbol'] ?? '')); + $name = trim((string) ($payload['name'] ?? '')); + + if ($isin !== '') { + $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin LIMIT 1'); + $stmt->execute(['isin' => $isin]); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + if ($symbol !== '' && $name !== '') { + $stmt = $this->pdo->prepare('SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name LIMIT 1'); + $stmt->execute([ + 'symbol' => $symbol, + 'name' => $name, + ]); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + return null; + } + + private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + } + + private function convertAmount(?float $amount, string $from, string $to): ?float + { + if ($amount === null) { + return null; + } + + $from = $this->normalizeCurrency($from); + $to = $this->normalizeCurrency($to); + if ($from === $to) { + return $amount; + } + + $fxService = \module_fn('boersenchecker', 'fx_service'); + if (!$fxService || !method_exists($fxService, 'convert')) { + return null; + } + + try { + $value = $fxService->convert($amount, $from, $to); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string + { + $normalized = strtoupper(trim((string) $value)); + return $normalized !== '' ? $normalized : $fallback; + } + + private function normalizeDateTimeLocal(?string $value): string + { + $value = trim((string) $value); + if ($value === '') { + return date('Y-m-d H:i:s'); + } + + $timestamp = strtotime($value); + return $timestamp !== false ? date('Y-m-d H:i:s', $timestamp) : date('Y-m-d H:i:s'); + } + + private function formatNumber(?float $value, int $scale = 2): string + { + if ($value === null) { + return 'n/a'; + } + + return number_format($value, $scale, ',', '.'); + } +}