'Leeway.tech (50 Abrufe/Tag)', 'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)', 'yahoo' => 'Yahoo Finanzen (ohne Schlüssel, Kürzel nötig)', ]; /** Anbieter, die ohne API-Schlüssel auskommen. */ private const OHNE_SCHLUESSEL = ['yahoo']; /** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */ private const ZEITGRENZE = 15; /** Mindestabstand zwischen zwei Kursseiten, in Sekunden. */ private const SEITENPAUSE = 1.0; /** Wie oft eine Kursseite geholt wird, wenn der Anbieter kurz streikt. */ private const SEITENVERSUCHE = 3; /** Wartezeit vor dem nächsten Versuch, in Sekunden. */ private const WIEDERHOLPAUSE = 2; /** Zeitpunkt der zuletzt geholten Kursseite. */ private static float $letzteSeite = 0.0; /** Statuscode der letzten Antwort – für die Frage, ob ein zweiter Versuch lohnt. */ private static int $letzterStatus = 0; /** * 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 === '' && !in_array($anbieter, self::OHNE_SCHLUESSEL, true)) { 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), 'yahoo' => self::yahoo($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. * * Seit dem 31. August 2025 beantwortet der Dienst die alten `/api/v3/`- * Adressen nur noch mit HTTP 403; gültig ist allein `/stable/`. * * @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/stable/search-isin?isin=' . rawurlencode($isin) . '&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/stable/quote?symbol=' . rawurlencode($symbol) . '&apikey=' . rawurlencode($key) ); return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price'])); } /** * Yahoo Finanzen hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com` * antwortet auf Anfragen ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb. * Übrig bleibt die Kursseite selbst, aus der der Kurs gelesen wird. * * Der Preis dafür ist Abhängigkeit von fremdem Markup – deshalb zwei Muster * und ein Abgleich mit dem Seitentitel, damit ein Umbau bei Yahoo als Fehler * auffällt und nicht als falscher Kurs in den Bestand wandert. * * Eine ISIN-Suche gibt es dort nicht mehr (`/lookup` und `/search` antworten * mit 404), das Kürzel muss deshalb in den Stammdaten stehen. * * @param array $wertpapier */ private static function yahoo(array $wertpapier): ?float { $symbol = trim((string)$wertpapier['symbol']); if ($symbol === '') { throw new \RuntimeException( 'Yahoo Finanzen kennt keine ISIN-Suche – bitte das Kürzel in den ' . 'Stammdaten hinterlegen (Xetra-Werte enden auf .DE, etwa ALV.DE).' ); } $html = self::holeSeite('https://de.finance.yahoo.com/quote/' . rawurlencode($symbol) . '/'); // Yahoo liefert über sein Auslieferungsnetz gelegentlich eine ältere // Fassung der Seite. Der angezeigte Stand („Markt geöffnet“, „Bei // Börsenschluss“) landet deshalb im Protokoll – daran lässt sich ein // alter Kurs erkennen, wenn einer auffällt. if (preg_match('/marketTimeNotice[^>]*>(.*?)<\/div>/s', $html, $stand) === 1) { DebugLog::schreibe( 'kursabruf', $symbol . ': ' . trim((string)preg_replace('/\s+/', ' ', strip_tags($stand[1]))) ); } return self::yahooKurs($html, $symbol); } /** * Der Kurs aus einer Yahoo-Kursseite. Getrennt vom Abruf, damit die * Auswertung ohne Netz geprüft werden kann. * * Yahoo liefert die Seite in zwei Ausprägungen aus – mal mit dem * maschinenlesbaren `` (Punkt als Dezimaltrenner), mal nur mit * dem angezeigten Kurs (deutsche Schreibweise). Beides kommt vor, teils am * selben Tag für verschiedene Papiere. */ public static function yahooKurs(string $html, string $symbol): ?float { // Der Titel lautet „Allianz SE (ALV.DE) Aktienkurs …“. Steht dort ein // anderes Kürzel, zeigt die Seite ein anderes Papier – dann lieber kein // Kurs als der falsche. if (preg_match('#(.*?)#is', $html, $titel) === 1 && !str_contains($titel[1], '(' . $symbol . ')') ) { throw new \RuntimeException( 'Yahoo zeigt unter dem Kürzel ' . $symbol . ' eine andere Seite an.' ); } $streamer = '/]*data-symbol="' . preg_quote($symbol, '/') . '"' . '[^>]*data-field="regularMarketPrice"[^>]*data-value="([0-9.]+)"/i'; if (preg_match($streamer, $html, $treffer) === 1) { return self::zahl($treffer[1]); } if (preg_match('/data-testid="qsp-price"[^>]*>([^<]+) */ private static function hole(string $url): array { $antwort = self::abruf($url, 'application/json', false); $daten = json_decode($antwort, true); if (!is_array($daten)) { throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.'); } return $daten; } /** * Eine Kursseite als HTML. Yahoo leitet von `/quote/X/` auf die Länderseite * weiter und liefert komprimiert – beides muss curl mitmachen. * * Zwischen zwei Seiten liegt eine Sekunde: Auf schnelle Serienabrufe * antwortet Yahoo mit HTTP 503. Bei einer Handvoll Papiere am Tag fällt die * Wartezeit nicht ins Gewicht. * * Ein 503 ist bei Yahoo vorübergehend und trifft auch Kürzel, die kurz * darauf wieder ausgeliefert werden – deshalb bis zu drei Versuche, statt * das Papier für den Tag verloren zu geben. */ private static function holeSeite(string $url): string { for ($versuch = 1; ; $versuch++) { $wartezeit = self::SEITENPAUSE - (microtime(true) - self::$letzteSeite); if (self::$letzteSeite > 0.0 && $wartezeit > 0.0) { usleep((int)($wartezeit * 1_000_000)); } self::$letzteSeite = microtime(true); try { return self::abruf($url, 'text/html', true); } catch (\RuntimeException $fehler) { if (self::$letzterStatus !== 503 || $versuch >= self::SEITENVERSUCHE) { throw $fehler; } sleep(self::WIEDERHOLPAUSE); } } } /** * Ein GET gegen den Kursdienst, Rohtext der Antwort. */ private static function abruf(string $url, string $accept, bool $folgen): string { $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 => $folgen, CURLOPT_MAXREDIRS => 3, CURLOPT_ENCODING => '', CURLOPT_USERAGENT => 'Finanzmanager Kursabruf', CURLOPT_HTTPHEADER => ['Accept: ' . $accept], ]); $antwort = curl_exec($kanal); $status = (int)curl_getinfo($kanal, CURLINFO_RESPONSE_CODE); $fehler = curl_error($kanal); curl_close($kanal); self::$letzterStatus = $status; // 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); } // Der Klartext des Dienstes wird mitgegeben: Ein 403 heißt bei Financial // Modeling Prep längst nicht mehr nur „Schlüssel falsch“, sondern meist // „diese Adresse gibt es nicht mehr“ – ohne den Text sucht man am // falschen Ende. $grund = self::meldung((string)$antwort); if ($status === 401) { throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP 401).' . $grund); } if ($status === 402) { throw new \RuntimeException('Im gebuchten Tarif nicht enthalten (HTTP 402).' . $grund); } if ($status === 403) { throw new \RuntimeException('Zugriff verweigert (HTTP 403).' . $grund); } 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 . '.' . $grund); } return (string)$antwort; } /** * 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; } /** * Die Begründung des Dienstes, gekürzt und einzeilig – als Anhang an die * eigene Fehlermeldung. Die Anbieter antworten mal mit JSON, mal mit * schlichtem Text, deshalb beides. */ private static function meldung(string $antwort): string { // Fehlerseiten der Kursseiten-Anbieter sind ganze HTML-Dokumente – davon // gehört nichts in eine Fehlermeldung. if (str_starts_with(ltrim($antwort), '<')) { return ''; } $daten = json_decode($antwort, true); $text = is_array($daten) ? (string)(self::ersterWert($daten, ['Error Message', 'error', 'message', 'Message']) ?? '') : $antwort; $text = trim(preg_replace('/\s+/', ' ', $text) ?? ''); if ($text === '') { return ''; } if (mb_strlen($text) > 200) { $text = mb_substr($text, 0, 200) . '…'; } return ' ' . $text; } 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, ]; } }