Finanzmanager/tests/27_KursabrufTest.php
fds2610-bot eb08cd6b83 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>
2026-08-13 15:08:27 +02:00

154 lines
6.4 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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');
}
}