pdo = \module_fn('boersenchecker', 'pdo'); \module_fn('boersenchecker', 'ensure_schema'); $this->user = \auth_user() ?? []; $this->isAdmin = \auth_is_admin(); $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local')); $this->availableOwners = $this->buildAvailableOwners(); if ($this->isAdmin) { $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? '')); if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) { $this->ownerSub = $requestedOwner; } } $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->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($this->marketDataMinIntervalMinutes <= 0) { $this->marketDataMinIntervalMinutes = 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'); $this->instrumentRegistry = new InstrumentRegistry( $this->pdo, $this->instrumentTable, $this->positionTable, $this->quoteTable, ); } 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, 'isAdmin' => $this->isAdmin, 'ownerSub' => $this->ownerSub, 'availableOwners' => array_values($this->availableOwners), 'defaultReportCurrency' => $this->defaultReportCurrency, 'fxMaxAgeHours' => $this->fxMaxAgeHours, 'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes, 'symbolSearchKeywords' => $this->symbolSearchKeywords, 'symbolSearchResults' => $this->symbolSearchResults, '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), 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), 'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'), ]; } 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_market_data_position' => $this->refreshMarketDataPosition(), 'refresh_market_data_all' => $this->refreshMarketDataAll(), 'search_symbol' => $this->searchSymbol(), '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) ); } $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? '')); $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? '')); $candidateIsin = trim((string) ($_GET['isin_candidate'] ?? '')); $candidateMarket = trim((string) ($_GET['market_candidate'] ?? '')); $candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency)); if ($editPosition === null) { $editPosition = [ 'instrument_name' => $candidateName, 'symbol' => $candidateSymbol, 'isin' => $candidateIsin, 'market' => $candidateMarket, 'quote_currency' => $candidateCurrency, 'purchase_currency' => $this->defaultReportCurrency, 'purchase_date' => date('Y-m-d'), ]; } else { if ($candidateName !== '') { $editPosition['instrument_name'] = $candidateName; } if ($candidateSymbol !== '') { $editPosition['symbol'] = $candidateSymbol; } if ($candidateIsin !== '') { $editPosition['isin'] = $candidateIsin; } if ($candidateMarket !== '') { $editPosition['market'] = $candidateMarket; } if ($candidateCurrency !== '') { $editPosition['quote_currency'] = $candidateCurrency; } } 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 refreshMarketDataPosition(): string { $positionId = (int) ($_POST['position_id'] ?? 0); $stmt = $this->pdo->prepare( 'SELECT p.instrument_id, i.name AS instrument_name, i.symbol, i.isin, 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 Symbol hinterlegt.'); } $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; } $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); if (empty($fxResult['ok'])) { throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); } $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { $displayTime = (string) \module_fn( 'boersenchecker', 'format_datetime_for_display', (string) ($apiResult['fetched_at'] ?? ''), (string) ($apiResult['source'] ?? 'alphavantage:global_quote') ); return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.'; } $storeResult = \module_fn( 'boersenchecker', 'store_market_quote', $instrumentId, (float) $apiResult['price'], $quoteCurrency, (string) $apiResult['fetched_at'], (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) ); if (!empty($storeResult['inserted'])) { return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; } return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; } private function refreshMarketDataAll(): string { $stmt = $this->pdo->prepare( 'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, 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; $stale = 0; $skipped = 0; $failed = 0; $errors = []; $bulkRows = []; foreach ($rows as $row) { $instrumentId = (int) ($row['id'] ?? 0); $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); 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->marketDataMinIntervalMinutes * 60)) { $reused++; continue; } $bulkRows[] = $row; } if ($bulkRows !== []) { $quoteCurrencies = array_values(array_unique(array_filter(array_map( fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), $bulkRows )))); $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); if (empty($fxResult['ok'])) { throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); } $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows); if (empty($bulkResult['ok'])) { throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.')); } $bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; foreach ($bulkRows as $row) { $instrumentId = (int) ($row['id'] ?? 0); $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); $apiResult = $bulkQuotes[$instrumentId] ?? null; if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) { $failed++; $errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.'; continue; } $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { $stale++; continue; } $storeResult = \module_fn( 'boersenchecker', 'store_market_quote', $instrumentId, (float) $apiResult['price'], $quoteCurrency, (string) $apiResult['fetched_at'], (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) ); if (!empty($storeResult['inserted'])) { $fetched++; } else { $reused++; } } } if ($errors !== []) { throw new RuntimeException( 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. ' . implode(' | ', array_slice($errors, 0, 3)) ); } return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.'; } private function searchSymbol(): string { $keywords = trim((string) ($_POST['search_keywords'] ?? '')); $this->symbolSearchKeywords = $keywords; $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords); $this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : []; if (empty($result['ok'])) { throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); } return (string) ($result['message'] ?? 'Suche abgeschlossen.'); } 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, (string) ($latestQuote['source'] ?? '')); $position['latest_price'] = (float) $latestQuote['price']; $position['latest_currency'] = (string) $latestQuote['currency']; $position['latest_quoted_at'] = (string) $latestQuote['quoted_at']; $position['latest_source'] = (string) ($latestQuote['source'] ?? ''); $position['current_total_base'] = $currentTotalBase; } else { $position['latest_price'] = null; $position['latest_currency'] = null; $position['latest_quoted_at'] = null; $position['latest_source'] = 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' => 'alphavantage:%', ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $row : null; } private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool { if (!is_array($latestQuote)) { return false; } $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); $incomingTimestamp = strtotime(trim($incomingQuotedAt)); if ($latestTimestamp === false || $incomingTimestamp === false) { return false; } return $incomingTimestamp <= $latestTimestamp; } private function upsertInstrument(array $payload): int { return $this->instrumentRegistry->save($payload); } 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, ?string $quoteSource = null): ?float { if ($amount === null) { return null; } $from = $this->normalizeCurrency($from); $to = $this->normalizeCurrency($to); if ($from === $to) { return $amount; } try { $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); 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 { $timezone = new \DateTimeZone('Europe/Berlin'); $value = trim((string) $value); if ($value === '') { return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); } $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone); if ($date instanceof \DateTimeImmutable) { return $date->format('Y-m-d H:i:s'); } try { return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s'); } catch (\Throwable) { return (new \DateTimeImmutable('now', $timezone))->format('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, ',', '.'); } private function buildAvailableOwners(): array { $owners = []; $currentSub = trim((string) ($this->user['sub'] ?? 'local')); $owners[$currentSub] = [ 'sub' => $currentSub, 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub, ]; if (!$this->isAdmin) { return $owners; } foreach (\modules()->knownAuthUsers() as $knownUser) { $sub = trim((string) ($knownUser['sub'] ?? '')); if ($sub === '') { continue; } $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub)); $owners[$sub] = [ 'sub' => $sub, 'label' => $label !== '' ? $label : $sub, ]; } uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label'])); return $owners; } }