Kursabruf: Yahoo Finanzen als dritter Anbieter, FMP-Adressen berichtigt

Financial Modeling Prep hat die /api/v3/-Adressen zum 31.08.2025 abgeschaltet;
sie antworten mit HTTP 403. Der Abruf nutzt jetzt /stable/quote und
/stable/search-isin und meldet den Klartext des Dienstes, statt jeden 403 als
abgelehnten Schlüssel auszugeben. HTTP 402 ist ein eigener Fall – im
Gratistarif fehlen dort ISIN-Suche und deutsche Börsenplätze.

Neu ist Yahoo Finanzen: ohne Schlüssel, ohne Kontingent, dafür muss das Kürzel
samt Handelsplatz in den Stammdaten stehen. Eine offene Schnittstelle gibt es
nicht mehr (query1.finance.yahoo.com antwortet ohne Sitzung mit HTTP 429, auch
mit Cookie und Crumb), gelesen wird deshalb die Kursseite selbst.

yahooKurs() liest beide Ausprägungen der Seite – <fin-streamer> und den
angezeigten Kurs in deutscher Schreibweise – und prüft am Seitentitel, dass die
Seite zum Kürzel gehört. Das eingebettete JSON bleibt ungenutzt: Es gehört zu
einem Fremd-Widget und liefert auf jeder Seite denselben papierfremden Wert.
Eine Sekunde Abstand zwischen zwei Seiten, bis zu drei Versuche bei HTTP 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fds2610-bot 2026-08-13 15:08:27 +02:00
parent be3ee6400e
commit eb08cd6b83
6 changed files with 486 additions and 34 deletions

View file

@ -14,6 +14,11 @@ Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca.
### 2. **Financial Modeling Prep (FMP)**
- **Angebot:** 250 kostenlose Anfragen pro Tag (mehr als ausreichend für 20 Positionen).
- **ISIN-Unterstützung:** Ja, ISIN kann für die Suche verwendet werden.
- **Stand August 2026 – Einschränkungen im kostenlosen Tarif:** Die alten `/api/v3/`-Adressen
sind seit dem 31.08.2025 abgeschaltet (HTTP 403), gültig ist nur noch `/stable/`. Die
ISIN-Suche (`/stable/search-isin`) und deutsche Börsenplätze (`EUNL.DE` und dergleichen)
sind kostenpflichtig und antworten mit HTTP 402. Für ein Depot mit deutschen ISINs bleibt
im Gratistarif also nichts übrig – dafür Leeway verwenden.
- **Daten:** Echtzeit- und historische Kurse, Fundamentaldaten, Dividenden.
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
- **Dokumentation:** [fmpcloud.io](https://site.financialmodelingprep.com/de/developer/docs)
@ -27,11 +32,34 @@ Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca.
- **Dokumentation:** [www.alphavantage.co](https://www.alphavantage.co)
---
### 4. **Yahoo Finance (unoffiziell)**
- **Angebot:** Kein offizieller API-Key nötig, aber Nutzung der öffentlichen URL-Struktur (z. B. `https://query1.finance.yahoo.com/v8/finance/chart/ISIN?interval=1d`).
- **ISIN-Unterstützung:** Teilweise, aber nicht alle ISINs werden direkt unterstützt. Oft muss die ISIN auf ein Börsenkürzel gemappt werden.
- **Daten:** Aktuelle Kurse, aber mit Verzögerung und ohne Garantie auf Stabilität.
- **Hinweis:** Keine offizielle API, daher kann die Nutzung eingeschränkt oder blockiert werden .
### 4. **Yahoo Finance (unoffiziell)** – im Finanzmanager umgesetzt
- **Angebot:** Kein API-Key, kein Kontingent.
- **ISIN-Unterstützung:** Keine mehr. `/lookup` und `/search` antworten mit HTTP 404, die
Suchschnittstelle `v1/finance/search` mit HTTP 429. Das Börsenkürzel muss deshalb in den
Stammdaten des Wertpapiers stehen.
- **Stand August 2026:** Auch `query1.finance.yahoo.com` (chart, quote, getcrumb) antwortet
ohne Sitzung durchgängig mit HTTP 429 – Cookie und Crumb helfen nicht. Erreichbar ist nur
die Kursseite `https://de.finance.yahoo.com/quote/ALV.DE/`, aus der der Kurs gelesen wird.
- **Fallstricke beim Lesen der Seite:**
- Die Seite wird in zwei Ausprägungen ausgeliefert – mit `<fin-streamer … data-value>`
(Punkt als Dezimaltrenner) oder nur mit dem angezeigten Kurs (`data-testid="qsp-price"`,
deutsche Schreibweise). Beides kommt am selben Tag vor.
- Das eingebettete JSON (`"regularMarketPrice":{"raw":…}`) gehört zu einem Fremd-Widget
und führt in die Irre: Es liefert auf jeder Seite denselben, papierfremden Wert.
- Die Kopfleiste führt DAX, Dow und andere als `<fin-streamer>` mit – nur der Treffer mit
passendem `data-symbol` zählt.
- Ohne `Accept: text/html` gibt das Auslieferungsnetz gelegentlich eine veraltete Fassung
heraus. Der angezeigte Kursstand landet im Debug-Log.
- Schnelle Serienabrufe beantwortet Yahoo mit HTTP 503, deshalb eine Sekunde Abstand.
Der 503 hält auch mal eine Viertelstunde an und trifft dann einzelne Kürzel, die kurz
darauf wieder ausgeliefert werden – im Browser funktionieren sie zur selben Zeit. Der
Abruf wiederholt deshalb bis zu dreimal.
- Ein **Browser-User-Agent schadet**: Mit Chrome-Kennung antwortete Yahoo mit HTTP 404,
mit `Finanzmanager Kursabruf` mit 200. Getarnt wird hier nichts.
- **Hinweis:** Keine offizielle Schnittstelle. Die Nutzungsbedingungen von Yahoo sehen
automatisierte Abrufe nicht vor; für ein privates Depot mit einer Handvoll Papieren am
Tag ist das vertretbar, als Grundlage für mehr aber nicht geeignet. Die `robots.txt`
sperrt `/quote/` nicht.
---
### 5. **Marketstack**

View file

@ -1,5 +1,44 @@
# Änderungen
## Kurse von Yahoo Finanzen (13.08.2026)
Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent.
Eine offene Schnittstelle gibt es dort nicht mehr – `query1.finance.yahoo.com` antwortet
ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb –, gelesen wird deshalb die Kursseite
selbst.
- Auswahl *Yahoo Finanzen* im Einstellungsdialog unter *Wertpapiere*; das Feld für den
API-Schlüssel bleibt dort leer.
- Das **Kürzel muss in den Stammdaten stehen** und den Handelsplatz nennen (`ALV.DE`,
`PTX.F`). Yahoo hat keine ISIN-Suche mehr, `/lookup` und `/search` antworten mit 404.
- `Kursabruf::yahooKurs()` liest beide Ausprägungen der Seite (`<fin-streamer>` und den
angezeigten Kurs in deutscher Schreibweise) und prüft am Seitentitel, dass die Seite zum
Kürzel gehört – ein Tippfehler bringt so keinen fremden Kurs in den Bestand.
- Eine Sekunde Abstand zwischen zwei Seiten; auf schnelle Serienabrufe antwortet Yahoo mit
HTTP 503. Weil dieser 503 vorübergehend ist und Kürzel trifft, die im Browser zur selben
Zeit funktionieren, wird bis zu dreimal angefragt. Der angezeigte Kursstand geht ins
Debug-Log, weil Yahoo gelegentlich eine ältere Fassung der Seite ausliefert.
Der Weg über fremdes Markup ist der empfindlichste der drei Anbieter: Baut Yahoo die Seite
um, meldet der Abruf einen Fehler, statt einen falschen Kurs zu speichern.
## Kursabruf: Financial Modeling Prep antwortete mit HTTP 403 (13.08.2026)
Financial Modeling Prep hat die alten `/api/v3/`-Adressen zum 31. August 2025 abgeschaltet;
sie beantworten seither jede Anfrage mit HTTP 403. Der Kursabruf sprach genau diese Adressen
an und meldete daraufhin *„Der API-Schlüssel wurde abgelehnt“* – eine falsche Fährte, denn
der Schlüssel war in Ordnung.
- `Kursabruf` nutzt für Financial Modeling Prep jetzt `/stable/quote` und `/stable/search-isin`.
- Fehlermeldungen nennen den Klartext des Dienstes. HTTP 403 heißt nicht mehr pauschal
„Schlüssel abgelehnt“, und HTTP 402 („im gebuchten Tarif nicht enthalten“) wird als
eigener Fall gemeldet statt als nacktes „antwortet mit HTTP 402“.
**Hinweis zum kostenlosen Tarif von Financial Modeling Prep:** Er umfasst weder die
ISIN-Suche noch deutsche Börsenplätze (`…​.DE`) – beides beantwortet der Dienst mit HTTP 402.
Für ein Depot mit deutschen ISINs ist dort auch nach dieser Korrektur nichts zu holen;
dafür ist Leeway.tech vorgesehen (Einstellungen → Wertpapiere).
## Detailseite eines Wertpapiers (13.08.2026)
Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert –

View file

@ -151,15 +151,37 @@ Ersatzwert wäre bequem, verstellte aber den Blick.
### Kursabruf
`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`):
`Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`):
* **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt
* **Financial Modeling Prep** – 250 Abrufe/Tag
* **Financial Modeling Prep** – 250 Abrufe/Tag, im Gratistarif ohne ISIN-Suche und ohne
deutsche Börsenplätze (beides antwortet mit HTTP 402)
* **Yahoo Finanzen** – kein Schlüssel, kein Kontingent, dafür muss das Kürzel in den
Stammdaten stehen
Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird
am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den
Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch
kein Kurs vorliegt.
Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene
Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das
halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für
heute noch kein Kurs vorliegt.
**Yahoo Finanzen** hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com`
antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb, und `/lookup` wie `/search`
sind abgeschaltet. Gelesen wird deshalb die Kursseite `de.finance.yahoo.com/quote/<Kürzel>/`.
* Das **Kürzel** muss den Handelsplatz nennen – `ALV.DE` für Xetra, `PTX.F` für Frankfurt.
Ohne Kürzel meldet der Abruf das ausdrücklich, denn raten lässt sich hier nichts.
* Yahoo liefert die Seite in zwei Ausprägungen aus: mal mit `<fin-streamer>` samt
`data-value`, mal nur mit dem angezeigten, deutsch formatierten Kurs. `yahooKurs()` liest
beides und prüft am Seitentitel, dass die Seite zum Kürzel gehört – sonst käme bei einem
Tippfehler der Kurs eines fremden Papiers in den Bestand.
* Zwischen zwei Seiten liegt **eine Sekunde**; auf schnelle Serienabrufe antwortet Yahoo
mit HTTP 503. Dieser 503 ist vorübergehend – er trifft auch Kürzel, die im Browser zur
selben Zeit funktionieren –, deshalb bis zu drei Versuche je Papier.
* Der angezeigte Kursstand („Markt geöffnet“, „Bei Börsenschluss“) geht ins Debug-Log:
Über sein Auslieferungsnetz gibt Yahoo gelegentlich eine ältere Fassung der Seite heraus.
Weil hier fremdes Markup gelesen wird, ist dieser Weg der empfindlichste der drei – ein
Umbau bei Yahoo fällt als Fehler auf, nicht als falscher Kurs.
Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf
gehört in einen Cron-Eintrag:

View file

@ -10,9 +10,10 @@ use Finanzmanager\Models\Wertpapier;
/**
* Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab.
*
* Zwei Anbieter, beide mit kostenlosem Kontingent (siehe `Aktienkurse.md`):
* **Leeway.tech** (50 Abrufe/Tag, kennt ISINs direkt) und **Financial Modeling
* Prep** (250/Tag). Bei rund zwanzig Positionen reicht beides.
* Drei Anbieter (siehe `Aktienkurse.md`): **Leeway.tech** (50 Abrufe/Tag, kennt
* ISINs direkt), **Financial Modeling Prep** (250/Tag, im Gratistarif ohne
* ISIN-Suche und ohne deutsche Börsenplätze) und **Yahoo Finanzen**, das ohne
* Schlüssel auskommt, dafür aber das Kürzel in den Stammdaten braucht.
*
* Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
@ -26,11 +27,30 @@ class Kursabruf
public const ANBIETER = [
'leeway' => '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.
*
@ -50,7 +70,7 @@ class Kursabruf
if (!array_key_exists($anbieter, self::ANBIETER)) {
return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]);
}
if ($key === '') {
if ($key === '' && !in_array($anbieter, self::OHNE_SCHLUESSEL, true)) {
return self::ergebnis(0, 1, 0, [
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
]);
@ -127,6 +147,7 @@ class Kursabruf
{
return match ($anbieter) {
'fmp' => self::fmp($key, $wertpapier),
'yahoo' => self::yahoo($wertpapier),
default => self::leeway($key, $wertpapier),
};
}
@ -174,6 +195,9 @@ class Kursabruf
* 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<string,mixed> $wertpapier
*/
private static function fmp(string $key, array $wertpapier): ?float
@ -187,8 +211,8 @@ class Kursabruf
}
$treffer = self::hole(
'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin)
. '&limit=1&apikey=' . rawurlencode($key)
'https://financialmodelingprep.com/stable/search-isin?isin=' . rawurlencode($isin)
. '&apikey=' . rawurlencode($key)
);
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
@ -200,19 +224,160 @@ class Kursabruf
}
$antwort = self::hole(
'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol)
. '?apikey=' . rawurlencode($key)
'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<string,mixed> $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 `<fin-streamer>` (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('#<title>(.*?)</title>#is', $html, $titel) === 1
&& !str_contains($titel[1], '(' . $symbol . ')')
) {
throw new \RuntimeException(
'Yahoo zeigt unter dem Kürzel ' . $symbol . ' eine andere Seite an.'
);
}
$streamer = '/<fin-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"[^>]*>([^<]+)</i', $html, $treffer) === 1) {
return self::zahl(self::deutscheZahl($treffer[1]));
}
return null;
}
/**
* „1.234,56“ wird zu 1234.56. Ohne Komma bleibt die Zahl, wie sie ist –
* Yahoo liefert je nach Ausprägung der Seite die eine oder die andere
* Schreibweise.
*/
private static function deutscheZahl(string $wert): string
{
$wert = trim(str_replace(["\u{00A0}", '&nbsp;', ' '], '', $wert));
if (str_contains($wert, ',')) {
$wert = str_replace(['.', ','], ['', '.'], $wert);
}
return $wert;
}
/**
* Ein GET gegen den Kursdienst, Antwort als Feld.
*
* @return array<mixed>
*/
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) {
@ -223,9 +388,11 @@ class Kursabruf
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::ZEITGRENZE,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_FOLLOWLOCATION => $folgen,
CURLOPT_MAXREDIRS => 3,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
]);
$antwort = curl_exec($kanal);
@ -233,6 +400,8 @@ class Kursabruf
$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;
@ -241,22 +410,30 @@ class Kursabruf
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 . ').');
// 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 . '.');
throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.' . $grund);
}
$daten = json_decode((string)$antwort, true);
if (!is_array($daten)) {
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
}
return $daten;
return (string)$antwort;
}
/**
@ -280,6 +457,35 @@ class Kursabruf
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)) {

View file

@ -157,16 +157,19 @@ include __DIR__ . '/../layout/kopf.php';
<?php endforeach; ?>
</select>
<p class="feldhinweis">
Beide kennen ISINs und haben ein kostenloses Tageskontingent, das für rund zwanzig
Positionen reicht.
Leeway kennt ISINs und hat ein kostenloses Tageskontingent, das für rund zwanzig
Positionen reicht. Financial Modeling Prep führt ISIN-Suche und deutsche
Börsenplätze nur im Bezahltarif. Yahoo Finanzen braucht keinen Schlüssel, dafür
muss bei jedem Wertpapier das Kürzel samt Handelsplatz stehen (etwa
<code>ALV.DE</code> für Xetra).
</p>
<label for="wertpapiere_api_key">API-Schlüssel</label>
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
<p class="feldhinweis">
Nach kostenloser Anmeldung beim gewählten Dienst. Ohne Schlüssel lassen sich Kurse
nur von Hand eintragen.
Nach kostenloser Anmeldung beim gewählten Dienst. Bei Yahoo Finanzen bleibt das Feld
leer; ohne Schlüssel lassen sich Kurse sonst nur von Hand eintragen.
</p>
<label class="ankreuzfeld">

154
tests/27_KursabrufTest.php Normal file
View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
use Finanzmanager\Services\Kursabruf;
/**
* Der Kursabruf hängt an fremden Servern – geprüft wird deshalb nur, was ohne
* Netz nachvollziehbar ist: die Adressen der Anbieter und die Aufbereitung
* ihrer Fehlermeldungen.
*
* Der Anlass: Financial Modeling Prep hat die alten `/api/v3/`-Adressen zum
* 31. August 2025 abgeschaltet und antwortet dort nur noch mit HTTP 403. Die
* Anwendung meldete daraufhin „Der API-Schlüssel wurde abgelehnt“ – falsche
* Fährte, der Schlüssel war in Ordnung.
*/
class KursabrufTest extends TestCase
{
protected string $testName = 'Test 27: Kursabruf';
public function run(): void
{
$this->adressen();
$this->meldungen();
$this->yahooAusMarkup();
$this->yahooOhneKuerzel();
}
/**
* Yahoo liefert die Kursseite in zwei Ausprägungen aus – beide müssen
* denselben Kurs ergeben. Das Markup stammt aus den echten Seiten vom
* 13.08.2026, gekürzt auf das Wesentliche.
*/
private function yahooAusMarkup(): void
{
$streamer = '<title>Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen</title>'
. '<fin-streamer key="price" class="last-price" data-symbol="ALV.DE"'
. ' data-field="regularMarketPrice" data-trend="none" data-pricehint="2"'
. ' data-value="441.7" active></fin-streamer>';
$this->assertEquals(441.7, Kursabruf::yahooKurs($streamer, 'ALV.DE'), 'Kurs aus <fin-streamer>');
$angezeigt = '<title>SAP SE (SAP.DE) Aktienkurs – Yahoo Finanzen</title>'
. '<span class="price" data-testid="qsp-price">177,22 </span>'
. '<span data-testid="qsp-price-change">+1,26 </span>';
$this->assertEquals(177.22, Kursabruf::yahooKurs($angezeigt, 'SAP.DE'), 'Kurs aus dem angezeigten Preis');
$tausender = '<title>Test (XYZ.DE) – Yahoo Finanzen</title>'
. '<span data-testid="qsp-price">1.234,56 </span>';
$this->assertEquals(1234.56, Kursabruf::yahooKurs($tausender, 'XYZ.DE'), 'Tausenderpunkt und Dezimalkomma');
// Die Indexleiste am Seitenkopf führt fremde Kurse mit – die dürfen
// nicht als Kurs des Papiers durchgehen.
$fremd = '<title>Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen</title>'
. '<fin-streamer data-symbol="^GDAXI" data-field="regularMarketPrice" data-value="26435.66"></fin-streamer>'
. '<fin-streamer data-symbol="ALV.DE" data-field="regularMarketPrice" data-value="441.7"></fin-streamer>';
$this->assertEquals(441.7, Kursabruf::yahooKurs($fremd, 'ALV.DE'), 'Der DAX-Stand der Kopfleiste zählt nicht');
$this->assertNull(
Kursabruf::yahooKurs('<title>Allianz SE (ALV.DE)</title><p>Umbau</p>', 'ALV.DE'),
'Ohne bekanntes Muster kommt kein Kurs statt eines geratenen'
);
// Yahoo zeigt für Papiere ohne Umsatz „--“ an – das ist kein Kurs.
$this->assertNull(
Kursabruf::yahooKurs(
'<title>Commerce Bancshares Inc (CB5.DE)</title><span data-testid="qsp-price">--</span>',
'CB5.DE'
),
'Ein Strich statt einer Zahl gilt nicht als Kurs'
);
// Ein falsches Kürzel darf keinen fremden Kurs in den Bestand tragen.
$fehler = '';
try {
Kursabruf::yahooKurs('<title>Siemens AG (SIE.DE) Aktienkurs</title>'
. '<span data-testid="qsp-price">210,00</span>', 'ALV.DE');
} catch (\RuntimeException $e) {
$fehler = $e->getMessage();
}
$this->assert(str_contains($fehler, 'ALV.DE'), 'Eine fremde Seite wird am Titel erkannt');
}
/** Ohne Kürzel kann Yahoo nichts finden – das muss die Meldung sagen. */
private function yahooOhneKuerzel(): void
{
$kurs = new ReflectionMethod(Kursabruf::class, 'yahoo');
$kurs->setAccessible(true);
$fehler = '';
try {
$kurs->invoke(null, ['id' => 0, 'symbol' => ' ', 'isin' => 'DE0008404005', 'name' => 'Allianz']);
} catch (\RuntimeException $e) {
$fehler = $e->getMessage();
}
$this->assert(str_contains($fehler, 'Kürzel'), 'Das fehlende Kürzel wird benannt');
$this->assert(str_contains($fehler, 'ALV.DE'), 'Die Meldung nennt ein Beispiel');
}
/** Keine abgeschalteten Adressen mehr im Quelltext. */
private function adressen(): void
{
$quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Services/Kursabruf.php');
$this->assert(
!str_contains($quelle, 'financialmodelingprep.com/api/v3'),
'Financial Modeling Prep wird nicht mehr über /api/v3 angesprochen'
);
$this->assert(
str_contains($quelle, 'financialmodelingprep.com/stable/quote?symbol='),
'Der Kurs kommt über /stable/quote'
);
$this->assert(
str_contains($quelle, 'financialmodelingprep.com/stable/search-isin?isin='),
'Die ISIN wird über /stable/search-isin aufgelöst'
);
}
/**
* Die Begründung des Dienstes gehört in die Fehlermeldung – sonst sucht man
* beim Schlüssel, wenn die Adresse das Problem ist.
*/
private function meldungen(): void
{
$meldung = new ReflectionMethod(Kursabruf::class, 'meldung');
$meldung->setAccessible(true);
$json = (string)$meldung->invoke(null, '{"Error Message": "Legacy Endpoint : nicht mehr\nunterstützt"}');
$this->assertEquals(
' Legacy Endpoint : nicht mehr unterstützt',
$json,
'Aus JSON wird der Klartext einzeilig übernommen'
);
$text = (string)$meldung->invoke(null, 'Restricted Endpoint: nicht im Tarif');
$this->assertEquals(
' Restricted Endpoint: nicht im Tarif',
$text,
'Antworten ohne JSON werden unverändert übernommen'
);
$this->assertEquals('', (string)$meldung->invoke(null, ' '), 'Leere Antworten hängen nichts an');
$this->assertEquals('', (string)$meldung->invoke(null, '[]'), 'Ein leeres Feld hängt nichts an');
$lang = (string)$meldung->invoke(null, str_repeat('x', 500));
// Ein Leerzeichen davor, 200 Zeichen Text, ein Auslassungszeichen dahinter.
$this->assertEquals(202, mb_strlen($lang), 'Lange Antworten werden auf 200 Zeichen gekürzt');
$this->assert(str_ends_with($lang, '…'), 'Gekürzte Antworten enden mit Auslassungszeichen');
}
}