'Leeway.tech (50 Abrufe/Tag)', 'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)', ]; /** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */ private const ZEITGRENZE = 15; /** * Holt Kurse für alle Wertpapiere mit Bestand. * * @param list>|null $wertpapiere sonst alle aktiven mit Bestand * @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list} */ public static function laufen(?array $wertpapiere = null): array { $anbieter = Einstellung::get('wertpapiere_kursdienst', 'leeway') ?? 'leeway'; $key = (string)Einstellung::get('wertpapiere_api_key', ''); $protokoll = []; $abgerufen = 0; $fehler = 0; $uebersprungen = 0; if (!array_key_exists($anbieter, self::ANBIETER)) { return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]); } if ($key === '') { return self::ergebnis(0, 1, 0, [ 'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).', ]); } $wertpapiere ??= self::mitBestand(); $heute = date('Y-m-d'); foreach ($wertpapiere as $wertpapier) { $name = (string)$wertpapier['name']; // Was heute schon einen Kurs hat, wird nicht erneut abgefragt – das // Freikontingent ist knapp, und mehr als einen Tageskurs speichert // die Anwendung ohnehin nicht. $vorhanden = Wertpapier::letzterKurs((int)$wertpapier['id'], $heute); if ($vorhanden !== null && (string)$vorhanden['datum'] === $heute) { $uebersprungen++; continue; } try { $kurs = self::kurs($anbieter, $key, $wertpapier); } catch (\Throwable $e) { $fehler++; $protokoll[] = $name . ': ' . $e->getMessage(); continue; } if ($kurs === null) { $fehler++; $protokoll[] = $name . ': kein Kurs geliefert.'; continue; } Wertpapier::kursSetzen( (int)$wertpapier['id'], $heute, $kurs, $anbieter, (string)($wertpapier['waehrung'] ?: 'EUR') ); $abgerufen++; $protokoll[] = $name . ': ' . number_format($kurs, 2, ',', '.'); } return self::ergebnis($abgerufen, $fehler, $uebersprungen, $protokoll); } /** * Wertpapiere, die überhaupt einen Kurs brauchen: solche mit Bestand. * Verkaufte Papiere kosten sonst jeden Tag Abrufe, ohne dass ihr Kurs * irgendwo auftauchte. * * @return list> */ public static function mitBestand(): array { $bestand = Depot::bestand(); $ids = array_column($bestand['positionen'], 'wertpapier_id'); return array_values(array_filter( Wertpapier::alle(true), static fn(array $wertpapier): bool => in_array((int)$wertpapier['id'], $ids, true) )); } /** * Ein einzelner Kurs. * * @param array $wertpapier */ public static function kurs(string $anbieter, string $key, array $wertpapier): ?float { return match ($anbieter) { 'fmp' => self::fmp($key, $wertpapier), default => self::leeway($key, $wertpapier), }; } /** * Leeway.tech kennt ISINs direkt: Die Stammdatenabfrage liefert das Kürzel, * mit dem der Kurs geholt wird. Ist das Kürzel am Wertpapier hinterlegt, * entfällt der erste Aufruf – das halbiert den Verbrauch. * * @param array $wertpapier */ private static function leeway(string $key, array $wertpapier): ?float { $symbol = trim((string)$wertpapier['symbol']); if ($symbol === '') { $isin = trim((string)$wertpapier['isin']); if ($isin === '') { throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.'); } $stamm = self::hole( 'https://api.leeway.tech/api/v1/public/general/isin/' . rawurlencode($isin) . '?apitoken=' . rawurlencode($key) ); $symbol = (string)self::ersterWert($stamm, ['Code', 'code', 'Ticker', 'ticker', 'symbol']); if ($symbol === '') { throw new \RuntimeException('Zur ISIN ' . $isin . ' liefert der Dienst kein Kürzel.'); } // Gefundene Kürzel werden gemerkt, damit der Umweg einmalig bleibt. Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol])); } $antwort = self::hole( 'https://api.leeway.tech/api/v1/public/realtime/' . rawurlencode($symbol) . '?apitoken=' . rawurlencode($key) ); return self::zahl(self::ersterWert($antwort, ['close', 'price', 'last', 'previousClose'])); } /** * Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt * der Kurs über die Kurzabfrage. * * @param array $wertpapier */ private static function fmp(string $key, array $wertpapier): ?float { $symbol = trim((string)$wertpapier['symbol']); if ($symbol === '') { $isin = trim((string)$wertpapier['isin']); if ($isin === '') { throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.'); } $treffer = self::hole( 'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin) . '&limit=1&apikey=' . rawurlencode($key) ); $symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']); if ($symbol === '') { throw new \RuntimeException('Zur ISIN ' . $isin . ' findet der Dienst kein Kürzel.'); } Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol])); } $antwort = self::hole( 'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol) . '?apikey=' . rawurlencode($key) ); return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price'])); } /** * Ein GET gegen den Kursdienst, Antwort als Feld. * * @return array */ private static function hole(string $url): array { $kanal = curl_init($url); if ($kanal === false) { throw new \RuntimeException('Verbindung konnte nicht aufgebaut werden.'); } curl_setopt_array($kanal, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => self::ZEITGRENZE, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_FOLLOWLOCATION => false, CURLOPT_USERAGENT => 'Finanzmanager Kursabruf', CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); $antwort = curl_exec($kanal); $status = (int)curl_getinfo($kanal, CURLINFO_RESPONSE_CODE); $fehler = curl_error($kanal); curl_close($kanal); // Der Schlüssel steht in der Adresse – er darf nirgends im Klartext // auftauchen, auch nicht in einer Fehlermeldung. $sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url; DebugLog::schreibe('kursabruf', 'GET ' . $sichtbar . ' → ' . $status); if ($antwort === false) { throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler); } if ($status === 401 || $status === 403) { throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP ' . $status . ').'); } if ($status === 429) { throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.'); } if ($status < 200 || $status >= 300) { throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.'); } $daten = json_decode((string)$antwort, true); if (!is_array($daten)) { throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.'); } return $daten; } /** * Der erste belegte der genannten Schlüssel. * * Mehrere Namen, weil die Dienste ihre Felder unterschiedlich benennen und * gelegentlich umbenennen – ein fehlender Kurs soll nicht am Feldnamen * scheitern. * * @param array $daten * @param list $schluessel */ private static function ersterWert(array $daten, array $schluessel): mixed { foreach ($schluessel as $name) { if (isset($daten[$name]) && $daten[$name] !== '' && $daten[$name] !== null) { return $daten[$name]; } } return null; } private static function zahl(mixed $wert): ?float { if ($wert === null || !is_numeric($wert)) { return null; } $kurs = round((float)$wert, 6); return $kurs > 0.0 ? $kurs : null; } /** * @param list $protokoll * @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list} */ private static function ergebnis(int $abgerufen, int $fehler, int $uebersprungen, array $protokoll): array { return [ 'abgerufen' => $abgerufen, 'fehler' => $fehler, 'uebersprungen' => $uebersprungen, 'protokoll' => $protokoll, ]; } }