pdo = \module_fn('boersenchecker', 'pdo'); \module_fn('boersenchecker', 'ensure_schema'); $user = \auth_user() ?? []; $this->ownerSub = trim((string) ($user['sub'] ?? 'local')); $settings = \modules()->settings('boersenchecker'); $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); if ($this->fxMaxAgeHours <= 0) { $this->fxMaxAgeHours = 6.0; } $this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); if ($this->marketDataMinIntervalMinutes <= 0) { $this->marketDataMinIntervalMinutes = 60; } $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' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') { try { $notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0)); } catch (\Throwable $e) { $error = $e->getMessage(); } } $portfolios = $this->fetchPortfolios(); $selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0)); if ($selectedPortfolioId <= 0 && $portfolios !== []) { $selectedPortfolioId = (int) $portfolios[0]['id']; } $positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : []; $selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0); if ($selectedInstrumentId <= 0 && $positions !== []) { $selectedInstrumentId = (int) $positions[0]['instrument_id']; } $latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions)))); foreach ($positions as &$position) { $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; $position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null; $position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : ''; $position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : ''; $position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : ''; $position['current_total_report'] = null; $position['gain_report'] = null; $position['gain_percent'] = null; if ($position['latest_price'] !== null) { $currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0); $currentReport = $this->convertAmount( $currentNative, (string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)), $this->defaultReportCurrency, (string) ($position['latest_source'] ?? '') ); $position['current_total_report'] = $currentReport; if ($position['purchase_total_report'] !== null && $currentReport !== null) { $gain = $currentReport - (float) $position['purchase_total_report']; $position['gain_report'] = $gain; $base = (float) $position['purchase_total_report']; $position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null; } } } unset($position); $selectedInstrument = null; foreach ($positions as $position) { if ((int) $position['instrument_id'] === $selectedInstrumentId) { $selectedInstrument = $position; break; } } return [ 'notice' => $notice, 'error' => $error, 'portfolios' => $portfolios, 'selectedPortfolioId' => $selectedPortfolioId, 'positions' => $positions, 'selectedInstrumentId' => $selectedInstrumentId, 'selectedInstrument' => $selectedInstrument, 'summary' => $this->buildSummary($positions), 'defaultReportCurrency' => $this->defaultReportCurrency, 'chartEndpoint' => '/module/boersenchecker/chart_data', 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), ]; } private function refreshCurrentQuotesForPortfolio(int $portfolioId): string { if ($portfolioId <= 0) { throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); } $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 AND p.portfolio_id = :portfolio_id' ); $stmt->execute([ 'owner_sub' => $this->ownerSub, 'portfolio_id' => $portfolioId, ]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; if ($rows === []) { throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.'); } $updated = 0; $reused = 0; $stale = 0; $bulkCandidates = []; foreach ($rows as $row) { $instrumentId = (int) ($row['id'] ?? 0); $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); if ($instrumentId <= 0 || $symbol === '') { continue; } $latest = $this->latestApiQuoteForInstrument($instrumentId); $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { $reused++; continue; } $bulkCandidates[] = $row; } if ($bulkCandidates !== []) { $quoteCurrencies = array_values(array_unique(array_filter(array_map( fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), $bulkCandidates )))); $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', $bulkCandidates); $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; foreach ($bulkCandidates as $row) { $instrumentId = (int) ($row['id'] ?? 0); $quote = $quotes[$instrumentId] ?? null; if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { continue; } $latest = $this->latestApiQuoteForInstrument($instrumentId); if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) { $stale++; continue; } $storeResult = \module_fn( 'boersenchecker', 'store_market_quote', $instrumentId, (float) $quote['price'], strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) ); if (!empty($storeResult['inserted'])) { $updated++; } else { $reused++; } } } return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.'; } 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 fetchPortfolioPositions(int $portfolioId): array { $stmt = $this->pdo->prepare( 'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, 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 AND p.portfolio_id = :portfolio_id ORDER BY i.name ASC' ); $stmt->execute([ 'owner_sub' => $this->ownerSub, 'portfolio_id' => $portfolioId, ]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; foreach ($rows as &$row) { $quantity = (float) ($row['quantity'] ?? 0); $purchasePrice = (float) ($row['purchase_price'] ?? 0); $fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0; $purchaseTotal = ($quantity * $purchasePrice) + $fees; $row['purchase_total'] = $purchaseTotal; $row['purchase_total_report'] = $this->convertAmount( $purchaseTotal, (string) ($row['purchase_currency'] ?? $this->defaultReportCurrency), $this->defaultReportCurrency ); } unset($row); return $rows; } private function fetchLatestQuotes(array $instrumentIds): array { $result = []; if ($instrumentIds === []) { return $result; } $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); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { $instrumentId = (int) $row['instrument_id']; if (!isset($result[$instrumentId])) { $result[$instrumentId] = $row; } } return $result; } 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 buildSummary(array $positions): array { $invested = 0.0; $current = 0.0; $hasInvested = false; $hasCurrent = false; $best = null; $worst = null; foreach ($positions as $position) { if (is_numeric($position['purchase_total_report'] ?? null)) { $invested += (float) $position['purchase_total_report']; $hasInvested = true; } if (is_numeric($position['current_total_report'] ?? null)) { $current += (float) $position['current_total_report']; $hasCurrent = true; } if (is_numeric($position['gain_percent'] ?? null)) { if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) { $best = $position; } if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) { $worst = $position; } } } return [ 'positions' => count($positions), 'invested' => $hasInvested ? $invested : null, 'current' => $hasCurrent ? $current : null, 'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null, 'best' => $best, 'worst' => $worst, ]; } private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float { if ($amount === null) { return null; } $from = $this->normalizeCurrency($from, $this->defaultReportCurrency); $to = $this->normalizeCurrency($to, $this->defaultReportCurrency); 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; } }