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; } $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); $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(); } } $instruments = $this->fetchInstruments(); $selectedInstrumentId = $this->selectedInstrumentOverrideId > 0 ? $this->selectedInstrumentOverrideId : (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0)); if ($selectedInstrumentId <= 0 && $instruments !== []) { $selectedInstrumentId = (int) $instruments[0]['id']; } $selectedInstrument = null; foreach ($instruments as $instrument) { if ((int) $instrument['id'] === $selectedInstrumentId) { $selectedInstrument = $instrument; break; } } $quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : []; $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 = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) { $selectedInstrument = [ 'id' => 0, 'name' => $candidateName, 'symbol' => $candidateSymbol, 'isin' => $candidateIsin, 'market' => $candidateMarket, 'quote_currency' => $candidateCurrency, 'wkn' => '', ]; } return [ 'notice' => $notice, 'error' => $error, 'instruments' => $instruments, 'selectedInstrument' => $selectedInstrument, 'selectedInstrumentId' => $selectedInstrumentId, 'quotes' => $quotes, 'searchKeywords' => $this->searchKeywords, 'searchResults' => $this->searchResults, 'defaultReportCurrency' => $this->defaultReportCurrency, '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_instrument' => $this->saveInstrument(), 'save_quote' => $this->saveQuote(), 'delete_quote' => $this->deleteQuote(), 'refresh_market_data_instrument' => $this->refreshInstrumentQuote(), 'search_symbol' => $this->searchSymbol(), default => '', }; } private function fetchInstruments(): array { $stmt = $this->pdo->prepare( 'SELECT DISTINCT i.* FROM ' . $this->instrumentTable . ' i INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id WHERE p.owner_sub = :owner_sub ORDER BY i.name ASC' ); $stmt->execute(['owner_sub' => $this->ownerSub]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } private function fetchQuotes(int $instrumentId): array { $stmt = $this->pdo->prepare( 'SELECT * FROM ' . $this->quoteTable . ' WHERE instrument_id = :instrument_id ORDER BY quoted_at DESC, created_at DESC, id DESC LIMIT 30' ); $stmt->execute(['instrument_id' => $instrumentId]); return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; } private function saveInstrument(): string { $instrumentId = (int) ($_POST['instrument_id'] ?? 0); if ($instrumentId <= 0) { throw new RuntimeException('Bitte eine Aktie auswaehlen.'); } $this->assertInstrumentAccessible($instrumentId); $resolvedId = $this->instrumentRegistry->save([ 'id' => $instrumentId, 'name' => $_POST['instrument_name'] ?? '', 'symbol' => $_POST['symbol'] ?? '', 'isin' => $_POST['isin'] ?? '', 'wkn' => $_POST['wkn'] ?? '', 'market' => $_POST['market'] ?? '', 'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency, ]); $this->selectedInstrumentOverrideId = $resolvedId; return $resolvedId === $instrumentId ? 'Aktie aktualisiert.' : 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.'; } private function saveQuote(): string { $instrumentId = (int) ($_POST['instrument_id'] ?? 0); $price = (float) ($_POST['quote_price'] ?? 0); if ($instrumentId <= 0 || $price <= 0) { throw new RuntimeException('Bitte Aktie und Kurs angeben.'); } $this->assertInstrumentAccessible($instrumentId); $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' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, 'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')), 'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual', ]); return 'Kurs gespeichert.'; } private function deleteQuote(): string { $quoteId = (int) ($_POST['quote_id'] ?? 0); if ($quoteId <= 0) { throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.'); } $stmt = $this->pdo->prepare( 'SELECT q.instrument_id FROM ' . $this->quoteTable . ' q WHERE q.id = :id LIMIT 1' ); $stmt->execute(['id' => $quoteId]); $instrumentId = (int) $stmt->fetchColumn(); $this->assertInstrumentAccessible($instrumentId); $stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id'); $stmt->execute(['id' => $quoteId]); return 'Kurs geloescht.'; } private function refreshInstrumentQuote(): string { $instrumentId = (int) ($_POST['instrument_id'] ?? 0); $instrument = $this->assertInstrumentAccessible($instrumentId); $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); if ($symbol === '') { throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.'); } $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); if (empty($apiResult['ok'])) { throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); } $quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; $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; $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); 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 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) ); return !empty($storeResult['inserted']) ? 'Alpha-Vantage-Kurs gespeichert.' : 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.'; } private function searchSymbol(): string { $this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? '')); $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords); $this->searchResults = 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 assertInstrumentAccessible(int $instrumentId): array { if ($instrumentId <= 0) { throw new RuntimeException('Aktie nicht gefunden.'); } $stmt = $this->pdo->prepare( 'SELECT DISTINCT i.* FROM ' . $this->instrumentTable . ' i INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id WHERE i.id = :id AND p.owner_sub = :owner_sub LIMIT 1' ); $stmt->execute([ 'id' => $instrumentId, 'owner_sub' => $this->ownerSub, ]); $instrument = $stmt->fetch(PDO::FETCH_ASSOC); if (!is_array($instrument)) { throw new RuntimeException('Aktie ist nicht verfuegbar.'); } return $instrument; } 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 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; } }