asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-25 00:17:31 +02:00
parent 46111d8988
commit ac2d95fc56
140 changed files with 1168 additions and 1984 deletions

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class HomePage
{
private PDO $pdo;
private string $ownerSub;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
public function __construct()
{
$this->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' => '/api/boersenchecker/index.php?path=v1/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;
}
}