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:
parent
be3ee6400e
commit
eb08cd6b83
6 changed files with 486 additions and 34 deletions
|
|
@ -14,6 +14,11 @@ Für den kostenlosen, tagesaktuellen Abruf von Aktienkursen mit ISIN-Codes (ca.
|
||||||
### 2. **Financial Modeling Prep (FMP)**
|
### 2. **Financial Modeling Prep (FMP)**
|
||||||
- **Angebot:** 250 kostenlose Anfragen pro Tag (mehr als ausreichend für 20 Positionen).
|
- **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.
|
- **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.
|
- **Daten:** Echtzeit- und historische Kurse, Fundamentaldaten, Dividenden.
|
||||||
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
|
- **Registrierung:** Kostenlos, API-Key nach Anmeldung.
|
||||||
- **Dokumentation:** [fmpcloud.io](https://site.financialmodelingprep.com/de/developer/docs)
|
- **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)
|
- **Dokumentation:** [www.alphavantage.co](https://www.alphavantage.co)
|
||||||
|
|
||||||
---
|
---
|
||||||
### 4. **Yahoo Finance (unoffiziell)**
|
### 4. **Yahoo Finance (unoffiziell)** – im Finanzmanager umgesetzt
|
||||||
- **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`).
|
- **Angebot:** Kein API-Key, kein Kontingent.
|
||||||
- **ISIN-Unterstützung:** Teilweise, aber nicht alle ISINs werden direkt unterstützt. Oft muss die ISIN auf ein Börsenkürzel gemappt werden.
|
- **ISIN-Unterstützung:** Keine mehr. `/lookup` und `/search` antworten mit HTTP 404, die
|
||||||
- **Daten:** Aktuelle Kurse, aber mit Verzögerung und ohne Garantie auf Stabilität.
|
Suchschnittstelle `v1/finance/search` mit HTTP 429. Das Börsenkürzel muss deshalb in den
|
||||||
- **Hinweis:** Keine offizielle API, daher kann die Nutzung eingeschränkt oder blockiert werden .
|
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**
|
### 5. **Marketstack**
|
||||||
|
|
|
||||||
39
Changes.md
39
Changes.md
|
|
@ -1,5 +1,44 @@
|
||||||
# Änderungen
|
# Ä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)
|
## Detailseite eines Wertpapiers (13.08.2026)
|
||||||
|
|
||||||
Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert –
|
Bisher endete der Weg zu einem Wertpapier in einer Tabellenzeile: Stückzahl, Kurs, Wert –
|
||||||
|
|
|
||||||
|
|
@ -151,15 +151,37 @@ Ersatzwert wäre bequem, verstellte aber den Blick.
|
||||||
|
|
||||||
### Kursabruf
|
### 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
|
* **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
|
Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene
|
||||||
am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den
|
Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das
|
||||||
Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch
|
halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für
|
||||||
kein Kurs vorliegt.
|
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
|
Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf
|
||||||
gehört in einen Cron-Eintrag:
|
gehört in einen Cron-Eintrag:
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ use Finanzmanager\Models\Wertpapier;
|
||||||
/**
|
/**
|
||||||
* Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab.
|
* Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab.
|
||||||
*
|
*
|
||||||
* Zwei Anbieter, beide mit kostenlosem Kontingent (siehe `Aktienkurse.md`):
|
* Drei Anbieter (siehe `Aktienkurse.md`): **Leeway.tech** (50 Abrufe/Tag, kennt
|
||||||
* **Leeway.tech** (50 Abrufe/Tag, kennt ISINs direkt) und **Financial Modeling
|
* ISINs direkt), **Financial Modeling Prep** (250/Tag, im Gratistarif ohne
|
||||||
* Prep** (250/Tag). Bei rund zwanzig Positionen reicht beides.
|
* 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
|
* Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs
|
||||||
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
|
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
|
||||||
|
|
@ -26,11 +27,30 @@ class Kursabruf
|
||||||
public const ANBIETER = [
|
public const ANBIETER = [
|
||||||
'leeway' => 'Leeway.tech (50 Abrufe/Tag)',
|
'leeway' => 'Leeway.tech (50 Abrufe/Tag)',
|
||||||
'fmp' => 'Financial Modeling Prep (250 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. */
|
/** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */
|
||||||
private const ZEITGRENZE = 15;
|
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.
|
* Holt Kurse für alle Wertpapiere mit Bestand.
|
||||||
*
|
*
|
||||||
|
|
@ -50,7 +70,7 @@ class Kursabruf
|
||||||
if (!array_key_exists($anbieter, self::ANBIETER)) {
|
if (!array_key_exists($anbieter, self::ANBIETER)) {
|
||||||
return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $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, [
|
return self::ergebnis(0, 1, 0, [
|
||||||
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
|
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
|
||||||
]);
|
]);
|
||||||
|
|
@ -127,6 +147,7 @@ class Kursabruf
|
||||||
{
|
{
|
||||||
return match ($anbieter) {
|
return match ($anbieter) {
|
||||||
'fmp' => self::fmp($key, $wertpapier),
|
'fmp' => self::fmp($key, $wertpapier),
|
||||||
|
'yahoo' => self::yahoo($wertpapier),
|
||||||
default => self::leeway($key, $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
|
* Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt
|
||||||
* der Kurs über die Kurzabfrage.
|
* 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
|
* @param array<string,mixed> $wertpapier
|
||||||
*/
|
*/
|
||||||
private static function fmp(string $key, array $wertpapier): ?float
|
private static function fmp(string $key, array $wertpapier): ?float
|
||||||
|
|
@ -187,8 +211,8 @@ class Kursabruf
|
||||||
}
|
}
|
||||||
|
|
||||||
$treffer = self::hole(
|
$treffer = self::hole(
|
||||||
'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin)
|
'https://financialmodelingprep.com/stable/search-isin?isin=' . rawurlencode($isin)
|
||||||
. '&limit=1&apikey=' . rawurlencode($key)
|
. '&apikey=' . rawurlencode($key)
|
||||||
);
|
);
|
||||||
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
|
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
|
||||||
|
|
||||||
|
|
@ -200,19 +224,160 @@ class Kursabruf
|
||||||
}
|
}
|
||||||
|
|
||||||
$antwort = self::hole(
|
$antwort = self::hole(
|
||||||
'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol)
|
'https://financialmodelingprep.com/stable/quote?symbol=' . rawurlencode($symbol)
|
||||||
. '?apikey=' . rawurlencode($key)
|
. '&apikey=' . rawurlencode($key)
|
||||||
);
|
);
|
||||||
|
|
||||||
return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price']));
|
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}", ' ', ' '], '', $wert));
|
||||||
|
|
||||||
|
if (str_contains($wert, ',')) {
|
||||||
|
$wert = str_replace(['.', ','], ['', '.'], $wert);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $wert;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein GET gegen den Kursdienst, Antwort als Feld.
|
* Ein GET gegen den Kursdienst, Antwort als Feld.
|
||||||
*
|
*
|
||||||
* @return array<mixed>
|
* @return array<mixed>
|
||||||
*/
|
*/
|
||||||
private static function hole(string $url): array
|
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);
|
$kanal = curl_init($url);
|
||||||
if ($kanal === false) {
|
if ($kanal === false) {
|
||||||
|
|
@ -223,9 +388,11 @@ class Kursabruf
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => self::ZEITGRENZE,
|
CURLOPT_TIMEOUT => self::ZEITGRENZE,
|
||||||
CURLOPT_CONNECTTIMEOUT => 5,
|
CURLOPT_CONNECTTIMEOUT => 5,
|
||||||
CURLOPT_FOLLOWLOCATION => false,
|
CURLOPT_FOLLOWLOCATION => $folgen,
|
||||||
|
CURLOPT_MAXREDIRS => 3,
|
||||||
|
CURLOPT_ENCODING => '',
|
||||||
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
|
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
|
||||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$antwort = curl_exec($kanal);
|
$antwort = curl_exec($kanal);
|
||||||
|
|
@ -233,6 +400,8 @@ class Kursabruf
|
||||||
$fehler = curl_error($kanal);
|
$fehler = curl_error($kanal);
|
||||||
curl_close($kanal);
|
curl_close($kanal);
|
||||||
|
|
||||||
|
self::$letzterStatus = $status;
|
||||||
|
|
||||||
// Der Schlüssel steht in der Adresse – er darf nirgends im Klartext
|
// Der Schlüssel steht in der Adresse – er darf nirgends im Klartext
|
||||||
// auftauchen, auch nicht in einer Fehlermeldung.
|
// auftauchen, auch nicht in einer Fehlermeldung.
|
||||||
$sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url;
|
$sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url;
|
||||||
|
|
@ -241,22 +410,30 @@ class Kursabruf
|
||||||
if ($antwort === false) {
|
if ($antwort === false) {
|
||||||
throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler);
|
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) {
|
if ($status === 429) {
|
||||||
throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.');
|
throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.');
|
||||||
}
|
}
|
||||||
if ($status < 200 || $status >= 300) {
|
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);
|
return (string)$antwort;
|
||||||
if (!is_array($daten)) {
|
|
||||||
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $daten;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -280,6 +457,35 @@ class Kursabruf
|
||||||
return null;
|
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
|
private static function zahl(mixed $wert): ?float
|
||||||
{
|
{
|
||||||
if ($wert === null || !is_numeric($wert)) {
|
if ($wert === null || !is_numeric($wert)) {
|
||||||
|
|
|
||||||
|
|
@ -157,16 +157,19 @@ include __DIR__ . '/../layout/kopf.php';
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
<p class="feldhinweis">
|
<p class="feldhinweis">
|
||||||
Beide kennen ISINs und haben ein kostenloses Tageskontingent, das für rund zwanzig
|
Leeway kennt ISINs und hat ein kostenloses Tageskontingent, das für rund zwanzig
|
||||||
Positionen reicht.
|
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>
|
</p>
|
||||||
|
|
||||||
<label for="wertpapiere_api_key">API-Schlüssel</label>
|
<label for="wertpapiere_api_key">API-Schlüssel</label>
|
||||||
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
|
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
|
||||||
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
|
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
|
||||||
<p class="feldhinweis">
|
<p class="feldhinweis">
|
||||||
Nach kostenloser Anmeldung beim gewählten Dienst. Ohne Schlüssel lassen sich Kurse
|
Nach kostenloser Anmeldung beim gewählten Dienst. Bei Yahoo Finanzen bleibt das Feld
|
||||||
nur von Hand eintragen.
|
leer; ohne Schlüssel lassen sich Kurse sonst nur von Hand eintragen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label class="ankreuzfeld">
|
<label class="ankreuzfeld">
|
||||||
|
|
|
||||||
154
tests/27_KursabrufTest.php
Normal file
154
tests/27_KursabrufTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue