diff --git a/Aktienkurse.md b/Aktienkurse.md index 5c07656..a2967c9 100644 --- a/Aktienkurse.md +++ b/Aktienkurse.md @@ -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 `` + (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 `` 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** diff --git a/Changes.md b/Changes.md index 2923071..2e23dc7 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,74 @@ # Ä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 (`` 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 – +und wer mehr wissen wollte, musste sich die Angaben aus Stammdaten, Depotbuchungen und +Auswertungen zusammensuchen. Der Name in der Übersicht war kein Link. + +**Die neue Seite** (`/wertpapiere/papier/{id}`, `Views/wertpapiere/papier.php`, +`WertpapiereController::papier()`) +- Erreichbar über den Wertpapiernamen in der Bestandsübersicht und in der Liste der + Depotbuchungen sowie über *Details* in den Stammdaten. +- Oben die Kennzahlen über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter + und realisierter Gewinn, Erträge. +- Darunter zwei Kacheln: **Stammdaten** (ISIN, WKN, Art, Währung, Kürzel, Status, erste + und letzte Buchung) und **Kurs** (letzter Kurs mit Datum und Quelle, Hoch und Tief der + letzten zwölf Monate, Einstand je Stück). +- **Bestand je Depot** – ein Papier kann in mehreren Depots liegen – und die + **Wertentwicklung** als dasselbe Diagramm wie in der Depotübersicht, auf dieses Papier + gerechnet. +- Unten die **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben + Vorgaben wie die Auswertungen. Dazu eine Zeile mit Zu- und Abgängen und den Erträgen des + Zeitraums. Die Kennzahlen oben bleiben davon unberührt. + +**Unter der Haube** +- `Depot::bestand()` und `Depot::entwicklung()` nehmen eine Wertpapiernummer entgegen. Die + Detailseite rechnet damit nicht selbst, sondern bekommt dieselben Zahlen wie die + Übersicht – beide können nicht auseinanderlaufen. +- Neu: `Wertpapier::kurseckwerte()` (Hoch, Tief, Zahl der Kurse eines Zeitraums) und + `Depotbuchung::datumsgrenzen()` (erste und letzte Buchung, wahlweise je Papier). +- `View::stueck()` formatiert Stückzahlen an einer Stelle statt in vier Ansichten. + ## Wertpapiergeschäft aus der Kontobuchung (13.08.2026) Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der diff --git a/README.md b/README.md index e0453e8..537ec84 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,9 @@ Haushalt wird beides parallel genutzt. übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das Depot in der Zuordnungsliste öffnet die Maske für Stück, Kurs und Nebenkosten, und daraus entstehen die Depotbuchung mit dem Einstandspreis und die Splitbuchung, die die - Gebühren und Steuern auf ihre Kategorien verteilt. + Gebühren und Steuern auf ihre Kategorien verteilt. Jeder Wertpapiername führt auf eine + Detailseite mit Stammdaten, Kurs, Bestand je Depot, Wertentwicklung und den Buchungen + eines wählbaren Zeitraums. * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. * **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index cc4d121..7e1ca45 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -116,6 +116,30 @@ Einstand. Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter Gewinn zählt weiter mit. +## Detailseite eines Wertpapiers + +Der Name in der Bestandsübersicht, in der Liste der Depotbuchungen und in den Stammdaten +führt auf `/wertpapiere/papier/{id}` – alles zu einem Papier auf einer Seite: + +* **Kennzahlen** über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter und + realisierter Gewinn, Erträge. Wer wissen will, was ein Papier gebracht hat, meint alles + seit dem ersten Kauf – nicht den gewählten Berichtszeitraum. +* **Stammdaten und Kurs** in je einer Kachel: ISIN, WKN, Art, Währung, Kürzel, Status, + erste und letzte Buchung; daneben der letzte Kurs mit Datum und Quelle, Hoch und Tief + der letzten zwölf Monate (`Wertpapier::kurseckwerte()`) und der Einstand je Stück. +* **Bestand je Depot**: Ein Papier kann in mehreren Depots liegen; gerechnet wird je Depot, + weil auch der Einstand je Depot geführt wird. Ist alles verkauft, bleibt die Seite + bestehen – realisierte Gewinne und Erträge zählen weiter. +* **Wertentwicklung** – dasselbe Diagramm wie in der Depotübersicht, nur auf dieses Papier + gerechnet, und nur, wenn es Kurshistorie gibt. +* **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben Vorgaben + wie die Auswertungen (`Services\Zeitraum`). Darunter eine Zeile mit Zu- und Abgängen und + den Erträgen des Zeitraums. + +Gerechnet wird nicht neu: `Depot::bestand()` nimmt eine Wertpapiernummer entgegen und +liefert dieselben Zahlen wie die Übersicht, nur auf ein Papier eingegrenzt. Damit können +Detailseite und Übersicht nicht auseinanderlaufen. + ## Kurse Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und @@ -127,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//`. + +* 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 `` 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: @@ -265,6 +311,13 @@ Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`. +`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte +Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot +und Papier lassen sich zugleich eingrenzen), Hoch und Tief der Kurse, die Datumsgrenzen +der Depotbuchungen, das Format der Stückzahlen sowie die gerenderte Seite: Stammdaten, +Kennzahlen und eine Buchungsliste, die dem Zeitraum folgt, während die Kennzahlen es nicht +tun. Den Klick vom Namen in der Übersicht prüft `tests/e2e/specs/wertpapierdetail.spec.js`. + `tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen, Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten diff --git a/public/index.php b/public/index.php index 6342804..078ac66 100644 --- a/public/index.php +++ b/public/index.php @@ -247,6 +247,7 @@ $router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new Zahlungsver // ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ────────────── $router->get('/wertpapiere', fn() => (new WertpapiereController())->index()); $router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung()); +$router->get('/wertpapiere/papier/{id}', fn(array $p) => (new WertpapiereController())->papier($p)); $router->get('/wertpapiere/buchungen', fn() => (new WertpapiereController())->buchungen()); $router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); diff --git a/public/static/app.css b/public/static/app.css index f55a3bc..0acf57d 100644 --- a/public/static/app.css +++ b/public/static/app.css @@ -1721,6 +1721,24 @@ table.bericht .wertlink:hover { margin: 0; } +/* Stammdaten einer Detailseite: Merkmal links, Wert rechts. Kein Formular, + also auch keine Feldpaare – nur zwei Spalten, die untereinander bündig + bleiben, und ein Umbruch auf eine Spalte, wenn es eng wird. */ +.merkmale { + display: grid; + grid-template-columns: minmax(6rem, auto) 1fr; + gap: .3rem .9rem; + margin: 0; +} + +.merkmale dt { + color: var(--text-gedaempft); + font-size: .85rem; + align-self: center; +} + +.merkmale dd { margin: 0; } + /* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen Spalten – so stehen Kommastellen untereinander. */ table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); } diff --git a/src/Controllers/WertpapiereController.php b/src/Controllers/WertpapiereController.php index cc8db9f..d622603 100644 --- a/src/Controllers/WertpapiereController.php +++ b/src/Controllers/WertpapiereController.php @@ -38,6 +38,54 @@ class WertpapiereController extends BaseController ]); } + /** + * Die Detailseite eines Wertpapiers: Stammdaten, Kurs, Bestand je Depot, + * die Wertentwicklung – und unten die Buchungen eines Zeitraums. + * + * Die Kennzahlen oben gelten für die ganze Historie: Wer wissen will, was + * ein Papier gebracht hat, meint alles seit dem ersten Kauf. Nur die Liste + * unten folgt dem gewählten Zeitraum, voreingestellt das laufende Jahr. + * + * @param array $params + */ + public function papier(array $params): void + { + $this->verlangeAnmeldung(); + + $id = (int)$params['id']; + $wertpapier = Wertpapier::find($id); + + if ($wertpapier === null) { + View::redirect('/wertpapiere/stammdaten', 'Dieses Wertpapier gibt es nicht.', 'fehler'); + return; + } + + $grenzen = Depotbuchung::datumsgrenzen($id); + $zeitraum = Zeitraum::berechne( + $this->query('zeitraum', 'aktuelles_jahr'), + $this->query('von'), + $this->query('bis'), + null, + $grenzen + ); + + $this->render('wertpapiere/papier.php', [ + 'titel' => (string)$wertpapier['name'], + 'wertpapier' => $wertpapier, + 'bestand' => Depot::bestand(null, null, $id), + 'kurs' => Wertpapier::letzterKurs($id), + 'eckwerte' => Wertpapier::kurseckwerte($id, date('Y-m-d', strtotime('-1 year'))), + 'entwicklung' => Depot::entwicklung(null, 12, null, $id), + 'grenzen' => $grenzen, + 'zeitraum' => $zeitraum, + 'buchungen' => Depotbuchung::liste([ + 'wertpapier_id' => $id, + 'von' => $zeitraum['von'], + 'bis' => $zeitraum['bis'], + ]), + ]); + } + public function buchungen(): void { $this->verlangeAnmeldung(); diff --git a/src/Models/Depotbuchung.php b/src/Models/Depotbuchung.php index 9dc578b..e78fe99 100644 --- a/src/Models/Depotbuchung.php +++ b/src/Models/Depotbuchung.php @@ -116,6 +116,34 @@ class Depotbuchung return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params); } + /** + * Erste und letzte Depotbuchung – für ein Wertpapier oder über alle. + * + * Die Detailseite zeigt daraus, seit wann das Papier geführt wird, und + * begrenzt damit den Zeitraum „Gesamter Zeitraum“ auf das, was es + * tatsächlich gibt. + * + * @return array{von: ?string, bis: ?string} + */ + public static function datumsgrenzen(?int $wertpapierId = null): array + { + $params = [Mandant::id()]; + $sql = 'SELECT MIN(datum) AS von, MAX(datum) AS bis + FROM depot_buchungen WHERE benutzer_id = ?'; + + if ($wertpapierId !== null) { + $sql .= ' AND wertpapier_id = ?'; + $params[] = $wertpapierId; + } + + $zeile = Database::fetchOne($sql, $params); + + return [ + 'von' => ($zeile['von'] ?? null) === null ? null : (string)$zeile['von'], + 'bis' => ($zeile['bis'] ?? null) === null ? null : (string)$zeile['bis'], + ]; + } + /** * Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein * Wertpapiergeschäft ist. diff --git a/src/Models/Wertpapier.php b/src/Models/Wertpapier.php index 1511001..755b83d 100644 --- a/src/Models/Wertpapier.php +++ b/src/Models/Wertpapier.php @@ -262,6 +262,32 @@ class Wertpapier return $kurse; } + /** + * Hoch und Tief eines Zeitraums, dazu die Zahl der bekannten Kurse – die + * Detailseite stellt sie neben den letzten Kurs. Ohne einen einzigen Kurs + * im Zeitraum gibt es nichts zu zeigen: null. + * + * @return array{tief: float, hoch: float, anzahl: int}|null + */ + public static function kurseckwerte(int $wertpapierId, string $von): ?array + { + $zeile = Database::fetchOne( + 'SELECT MIN(kurs) AS tief, MAX(kurs) AS hoch, COUNT(*) AS anzahl + FROM kurse WHERE wertpapier_id = ? AND datum >= ?', + [$wertpapierId, $von] + ); + + if ($zeile === false || (int)$zeile['anzahl'] === 0) { + return null; + } + + return [ + 'tief' => (float)$zeile['tief'], + 'hoch' => (float)$zeile['hoch'], + 'anzahl' => (int)$zeile['anzahl'], + ]; + } + /** @return list> */ public static function kursverlauf(int $wertpapierId, int $tage = 365): array { diff --git a/src/Services/Depot.php b/src/Services/Depot.php index 1e50ae1..e846f53 100644 --- a/src/Services/Depot.php +++ b/src/Services/Depot.php @@ -29,6 +29,10 @@ class Depot /** * Die Bestände eines Depots (oder aller Depots) zum Stichtag. * + * Mit `$wertpapierId` bleibt ein einziges Papier übrig – so rechnet die + * Detailseite dieselben Zahlen wie die Übersicht, nur je Depot statt für + * das ganze Depot. + * * @return array{ * positionen: list>, * einstand: float, @@ -39,8 +43,11 @@ class Depot * bewertbar: bool * } */ - public static function bestand(?int $kontoId = null, ?string $stichtag = null): array - { + public static function bestand( + ?int $kontoId = null, + ?string $stichtag = null, + ?int $wertpapierId = null + ): array { $stichtag ??= date('Y-m-d'); $kurse = Wertpapier::letzteKurse($stichtag); @@ -48,7 +55,7 @@ class Depot $realisiert = 0.0; $ertraege = 0.0; - foreach (self::buchungen($kontoId, $stichtag) as $buchung) { + foreach (self::buchungen($kontoId, $stichtag, $wertpapierId) as $buchung) { $schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id']; $position = $positionen[$schluessel] ?? [ @@ -283,12 +290,16 @@ class Depot * * @return list */ - public static function entwicklung(?int $kontoId = null, int $monate = 12, ?string $heute = null): array - { + public static function entwicklung( + ?int $kontoId = null, + int $monate = 12, + ?string $heute = null, + ?int $wertpapierId = null + ): array { $reihe = []; foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) { - $bestand = self::bestand($kontoId, $monat['bis']); + $bestand = self::bestand($kontoId, $monat['bis'], $wertpapierId); $reihe[] = [ 'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']), @@ -307,7 +318,7 @@ class Depot * * @return list> */ - private static function buchungen(?int $kontoId, string $bis): array + private static function buchungen(?int $kontoId, string $bis, ?int $wertpapierId = null): array { $params = [Mandant::id(), $bis]; $sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot @@ -321,6 +332,11 @@ class Depot $params[] = $kontoId; } + if ($wertpapierId !== null) { + $sql .= ' AND d.wertpapier_id = ?'; + $params[] = $wertpapierId; + } + return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params); } } diff --git a/src/Services/Kursabruf.php b/src/Services/Kursabruf.php index 4c55ef0..5dce4c9 100644 --- a/src/Services/Kursabruf.php +++ b/src/Services/Kursabruf.php @@ -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 $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 $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) { @@ -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)) { diff --git a/src/View.php b/src/View.php index fb70cb8..0fbd80d 100644 --- a/src/View.php +++ b/src/View.php @@ -55,6 +55,16 @@ class View return $mitWaehrung ? $text . "\u{00A0}€" : $text; } + /** + * Stückzahlen. Fonds werden in Bruchteilen geführt, Aktien in ganzen + * Stücken – dieselbe Spalte trägt beides, deshalb sechs Nachkommastellen + * ohne die Nullen, die niemand liest: „12“ statt „12,000000“. + */ + public static function stueck(mixed $wert): string + { + return rtrim(rtrim(number_format((float)$wert, 6, ',', '.'), '0'), ','); + } + /** * Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter – * date() ist immer englisch und intl ist keine Standarderweiterung. Der diff --git a/src/Views/einstellungen/index.php b/src/Views/einstellungen/index.php index bb0f08d..2b6be9d 100644 --- a/src/Views/einstellungen/index.php +++ b/src/Views/einstellungen/index.php @@ -157,16 +157,19 @@ include __DIR__ . '/../layout/kopf.php';

- 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 + ALV.DE für Xetra).

- 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.

- + + Details diff --git a/tests/26_WertpapierDetailTest.php b/tests/26_WertpapierDetailTest.php new file mode 100644 index 0000000..032f366 --- /dev/null +++ b/tests/26_WertpapierDetailTest.php @@ -0,0 +1,270 @@ +mandantAnlegen(); + $this->stammdaten(); + + $this->bestandFiltertAufEinPapier(); + $this->bestandLiegtInBeidenDepots(); + $this->kurseckwerteZeigenHochUndTief(); + $this->datumsgrenzenUmfassenNurDiesesPapier(); + $this->stueckOhneUeberfluessigeNullen(); + $this->seiteZeigtStammdatenUndBuchungen(); + $this->seiteFolgtDemZeitraum(); + $this->uebersichtVerlinktDieDetailseite(); + } + + private function stammdaten(): void + { + $this->depot = Konto::anlegen([ + 'name' => 'Detaildepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->zweitdepot = Konto::anlegen([ + 'name' => 'Zweitdepot', 'kontoart' => 'depot', 'anfangssaldo' => 0, 'aktiv' => 1, + ]); + $this->verrechnung = Konto::anlegen([ + 'name' => 'Detailverrechnung', 'kontoart' => 'bank', 'anfangssaldo' => 50000, 'aktiv' => 1, + ]); + + $this->papier = Wertpapier::findenOderAnlegen([ + 'name' => 'Detailaktie AG', 'isin' => 'DE0007654321', 'wkn' => '765432', 'art' => 'aktie', + ]); + $this->anderes = Wertpapier::findenOderAnlegen([ + 'name' => 'Nebenaktie SE', 'isin' => 'FR0001234567', 'art' => 'aktie', + ]); + + // Zehn Stück zu 100 € plus 10 € Gebühren, dann fünf davon zu 130 € + // verkauft, dazu eine Dividende – die Zahlen der Kennzahlenleiste. + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-01-10', 'vorgang' => 'kauf', + 'stueck' => 10, 'kurs' => 100, 'betrag' => 1000, 'gebuehren' => 10, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-03-20', 'vorgang' => 'verkauf', + 'stueck' => 5, 'kurs' => 130, 'betrag' => 650, 'gebuehren' => 5, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-05-02', 'vorgang' => 'dividende', + 'betrag' => 40, 'steuern' => 10, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + // Dasselbe Papier ein zweites Mal, in einem anderen Depot. + Depotbuchung::anlegen([ + 'konto_id' => $this->zweitdepot, 'wertpapier_id' => $this->papier, + 'datum' => '2026-06-15', 'vorgang' => 'kauf', + 'stueck' => 4, 'kurs' => 125, 'betrag' => 500, 'gebuehren' => 0, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + // Ein fremdes Papier, das auf der Detailseite nichts zu suchen hat. + Depotbuchung::anlegen([ + 'konto_id' => $this->depot, 'wertpapier_id' => $this->anderes, + 'datum' => '2025-11-11', 'vorgang' => 'kauf', + 'stueck' => 3, 'kurs' => 50, 'betrag' => 150, + 'verrechnungskonto_id' => $this->verrechnung, + ]); + + Wertpapier::kursSetzen($this->papier, '2026-07-01', 120.0, 'manuell'); + Wertpapier::kursSetzen($this->papier, '2026-08-01', 140.0, 'leeway'); + Wertpapier::kursSetzen($this->anderes, '2026-08-01', 60.0, 'manuell'); + } + + private function bestandFiltertAufEinPapier(): void + { + $bestand = Depot::bestand(null, '2026-12-31', $this->papier); + + foreach ($bestand['positionen'] as $position) { + $this->assertEquals( + $this->papier, + $position['wertpapier_id'], + 'Nur das gewählte Papier steht in den Positionen' + ); + } + + // 10 Stück für 1010 €, davon 5 verkauft: die Hälfte des Einstands geht + // ab (505 €), der Erlös von 645 € ergibt 140 € realisiert. + $this->assertEquals(140.0, $bestand['realisiert'], 'Der realisierte Gewinn gehört zu diesem Papier'); + $this->assertEquals(30.0, $bestand['ertraege'], 'Die Dividende zählt netto'); + $this->assertEquals(1005.0, $bestand['einstand'], '505 € aus dem ersten Depot, 500 € aus dem zweiten'); + $this->assertEquals(1260.0, $bestand['wert'], 'Neun Stück zum letzten Kurs von 140 €'); + } + + private function bestandLiegtInBeidenDepots(): void + { + $bestand = Depot::bestand(null, '2026-12-31', $this->papier); + $this->assertEquals(2, count($bestand['positionen']), 'Je Depot eine Position'); + + $stueck = 0.0; + foreach ($bestand['positionen'] as $position) { + $stueck += (float)$position['stueck']; + } + $this->assertEquals(9.0, $stueck, 'Fünf im ersten, vier im zweiten Depot'); + + // Mit Depot eingegrenzt bleibt nur eine Position übrig. + $eines = Depot::bestand($this->zweitdepot, '2026-12-31', $this->papier); + $this->assertEquals(1, count($eines['positionen']), 'Depot und Papier lassen sich zugleich eingrenzen'); + $this->assertEquals(4.0, $eines['positionen'][0]['stueck'], 'Vier Stück im Zweitdepot'); + } + + private function kurseckwerteZeigenHochUndTief(): void + { + $eckwerte = Wertpapier::kurseckwerte($this->papier, '2026-01-01'); + + $this->assertNotEmpty($eckwerte, 'Es gibt Eckwerte'); + $this->assertEquals(140.0, $eckwerte['hoch'], 'Der höchste Kurs'); + $this->assertEquals(120.0, $eckwerte['tief'], 'Der niedrigste Kurs'); + $this->assertEquals(2, $eckwerte['anzahl'], 'Zwei bekannte Kurse'); + + // Kurse anderer Papiere bleiben außen vor, ein leerer Zeitraum ergibt null. + $this->assertEquals(60.0, Wertpapier::kurseckwerte($this->anderes, '2026-01-01')['hoch'], 'Je Papier gerechnet'); + $this->assertNull(Wertpapier::kurseckwerte($this->papier, '2030-01-01'), 'Ohne Kurse im Zeitraum: null'); + } + + private function datumsgrenzenUmfassenNurDiesesPapier(): void + { + $grenzen = Depotbuchung::datumsgrenzen($this->papier); + $this->assertEquals('2026-01-10', $grenzen['von'], 'Erste Buchung des Papiers'); + $this->assertEquals('2026-06-15', $grenzen['bis'], 'Letzte Buchung des Papiers'); + + // Ohne Papier zählen alle – das fremde von 2025 verschiebt den Anfang. + $alle = Depotbuchung::datumsgrenzen(); + $this->assertEquals('2025-11-11', $alle['von'], 'Über alle Papiere reicht es weiter zurück'); + } + + private function stueckOhneUeberfluessigeNullen(): void + { + $this->assertEquals('12', View::stueck(12.0), 'Ganze Stücke ohne Nachkommastellen'); + $this->assertEquals('0', View::stueck(0.0), 'Null bleibt eine Null'); + $this->assertEquals('1,5', View::stueck(1.5), 'Bruchteile bleiben erhalten'); + $this->assertEquals('1.234,25', View::stueck(1234.25), 'Tausenderpunkt wie bei Beträgen'); + } + + private function seiteZeigtStammdatenUndBuchungen(): void + { + $seite = $this->detailseite('aktuelles_jahr'); + + $this->assert(str_contains($seite, 'Detailaktie AG'), 'Der Name steht auf der Seite'); + $this->assert(str_contains($seite, 'DE0007654321'), 'Die ISIN steht auf der Seite'); + $this->assert(str_contains($seite, '765432'), 'Die WKN steht auf der Seite'); + $this->assert(str_contains($seite, 'Detaildepot'), 'Das Depot mit Bestand wird genannt'); + $this->assert(str_contains($seite, 'Zweitdepot'), 'Auch das zweite Depot'); + $this->assert(!str_contains($seite, 'Nebenaktie SE'), 'Fremde Papiere bleiben draußen'); + + // Kennzahlen und Kursangaben. + $this->assert(str_contains($seite, '1.260,00'), 'Der bewertete Bestand steht in den Kennzahlen'); + $this->assert(str_contains($seite, '140,00'), 'Der letzte Kurs steht in der Kurskachel'); + $this->assert(str_contains($seite, 'leeway'), 'Die Quelle des Kurses wird genannt'); + $this->assert(str_contains($seite, '10.01.2026'), 'Die erste Buchung wird genannt'); + + // Die Buchungen des laufenden Jahres, mit Vorgängen im Klartext. + $this->assert(str_contains($seite, '20.03.2026'), 'Der Verkauf steht in der Liste'); + $this->assert(str_contains($seite, 'Dividende'), 'Die Dividende steht in der Liste'); + $this->assert( + substr_count($seite, '/wertpapiere/papier/' . $this->papier) > 0, + 'Der Zeitraumfilter zeigt auf dieselbe Seite' + ); + } + + private function seiteFolgtDemZeitraum(): void + { + // Das laufende Jahr ist 2026 – die Testdaten liegen darin. Ein Zeitraum + // ohne Buchungen muss die Liste leeren, ohne die Kennzahlen anzutasten: + // Die gelten für die ganze Historie. + $leer = $this->detailseite('benutzerdefiniert', '2026-09-01', '2026-09-30'); + + $this->assert(str_contains($leer, 'Keine Buchungen in diesem Zeitraum'), 'Leerer Zeitraum, leere Liste'); + $this->assert(!str_contains($leer, '20.03.2026'), 'Der Verkauf liegt außerhalb'); + $this->assert(str_contains($leer, '1.260,00'), 'Die Kennzahlen bleiben die der ganzen Historie'); + + // Der Kauf vom Januar steht dann nur noch bei den Stammdaten als erste + // Buchung, nicht mehr in der Liste darunter. + $eng = $this->detailseite('benutzerdefiniert', '2026-03-01', '2026-03-31'); + $this->assert(str_contains($eng, '20.03.2026'), 'Der Verkauf liegt im engen Zeitraum'); + $this->assertEquals(1, substr_count($eng, '10.01.2026'), 'Der Kauf vom Januar nicht'); + } + + private function uebersichtVerlinktDieDetailseite(): void + { + $bestand = Depot::bestand($this->depot, '2026-12-31'); + + $seite = $this->rendere('wertpapiere/index.php', [ + 'titel' => 'Wertpapiere', + 'depots' => Depot::depots(), + 'depot' => $this->depot, + 'bestand' => $bestand, + 'entwicklung' => [], + 'kursabruf' => false, + ]); + + $this->assert( + str_contains($seite, 'href="/wertpapiere/papier/' . $this->papier . '"'), + 'Der Wertpapiername in der Übersicht führt zur Detailseite' + ); + } + + /** Rendert die Detailseite so, wie der Controller sie zusammenstellt. */ + private function detailseite(string $vorgabe, string $von = '', string $bis = ''): string + { + $grenzen = Depotbuchung::datumsgrenzen($this->papier); + $zeitraum = Zeitraum::berechne($vorgabe, $von, $bis, '2026-08-13', $grenzen); + + return $this->rendere('wertpapiere/papier.php', [ + 'titel' => 'Detailaktie AG', + 'wertpapier' => Wertpapier::find($this->papier), + 'bestand' => Depot::bestand(null, '2026-12-31', $this->papier), + 'kurs' => Wertpapier::letzterKurs($this->papier), + 'eckwerte' => Wertpapier::kurseckwerte($this->papier, '2025-08-13'), + 'entwicklung' => [], + 'grenzen' => $grenzen, + 'zeitraum' => $zeitraum, + 'buchungen' => Depotbuchung::liste([ + 'wertpapier_id' => $this->papier, + 'von' => $zeitraum['von'], + 'bis' => $zeitraum['bis'], + ]), + ]); + } + + /** @param array $daten */ + private function rendere(string $view, array $daten): string + { + $daten += ['aktuellerPfad' => '/wertpapiere', 'flash' => [], 'benutzer' => null, 'csrfToken' => 'x']; + + extract($daten, EXTR_SKIP); + + ob_start(); + include dirname(__DIR__) . '/src/Views/' . $view; + return (string)ob_get_clean(); + } +} diff --git a/tests/27_KursabrufTest.php b/tests/27_KursabrufTest.php new file mode 100644 index 0000000..36e9b12 --- /dev/null +++ b/tests/27_KursabrufTest.php @@ -0,0 +1,154 @@ +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 = 'Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen' + . ''; + + $this->assertEquals(441.7, Kursabruf::yahooKurs($streamer, 'ALV.DE'), 'Kurs aus '); + + $angezeigt = 'SAP SE (SAP.DE) Aktienkurs – Yahoo Finanzen' + . '177,22 ' + . '+1,26 '; + + $this->assertEquals(177.22, Kursabruf::yahooKurs($angezeigt, 'SAP.DE'), 'Kurs aus dem angezeigten Preis'); + + $tausender = 'Test (XYZ.DE) – Yahoo Finanzen' + . '1.234,56 '; + + $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 = 'Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen' + . '' + . ''; + + $this->assertEquals(441.7, Kursabruf::yahooKurs($fremd, 'ALV.DE'), 'Der DAX-Stand der Kopfleiste zählt nicht'); + + $this->assertNull( + Kursabruf::yahooKurs('Allianz SE (ALV.DE)

Umbau

', '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( + 'Commerce Bancshares Inc (CB5.DE)--', + '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('Siemens AG (SIE.DE) Aktienkurs' + . '210,00', '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'); + } +} diff --git a/tests/e2e/specs/wertpapierdetail.spec.js b/tests/e2e/specs/wertpapierdetail.spec.js new file mode 100644 index 0000000..6f671f5 --- /dev/null +++ b/tests/e2e/specs/wertpapierdetail.spec.js @@ -0,0 +1,146 @@ +// @ts-check +const { test, expect } = require('@playwright/test') + +/* + * Vom Namen in der Depotübersicht zur Detailseite des Wertpapiers: Stammdaten, + * Kennzahlen und darunter die Buchungen eines Zeitraums – voreingestellt das + * laufende Jahr. + */ + +const BENUTZER = process.env.E2E_USER || 'e2e' +const PASSWORT = process.env.E2E_PASSWORD || 'e2e-testpasswort' + +const MARKE = Date.now().toString().slice(-6) +const DEPOT = `E2E Detaildepot ${MARKE}` +const GIRO = `E2E Detailgiro ${MARKE}` +const PAPIER = `E2E Detailaktie ${MARKE}` + +// Wie im Test des Wertpapiergeschäfts: Ein Papier wird über seine Kennung +// wiedergefunden, feste Kennungen hingen den zweiten Lauf an den ersten. +const ISIN = `DE0007${MARKE}` + +// Die Zeiträume rechnen mit dem Tagesdatum, nicht mit festen Jahreszahlen – +// sonst liefe der Test am 1. Januar ins Leere. +const HEUTE = new Date() +const JAHR = HEUTE.getFullYear() +const DIESES_JAHR = `${JAHR}-03-04` +const VORJAHR = `${JAHR - 1}-03-04` + +async function anmelden(page) { + await page.goto('/login') + if (page.url().includes('/einrichtung')) { + throw new Error( + 'Instanz ist noch nicht eingerichtet: ' + + 'docker compose exec -u www-data php php scripts/e2e-benutzer.php' + ) + } + await page.fill('#benutzername', BENUTZER) + await page.fill('#passwort', PASSWORT) + await page.click('button[type="submit"]') + await expect(page).toHaveURL(/\/$/) +} + +async function kontoAnlegen(page, name, kontoart, anfangssaldo) { + await page.goto('/konten/neu') + await page.fill('#name', name) + await page.selectOption('#kontoart', kontoart) + await page.fill('#anfangssaldo', anfangssaldo) + await page.click('.formular button[type="submit"]') + await expect(page).toHaveURL(/\/konten\/\d+\/bearbeiten/) +} + +/** Ein Kauf über die Maske unter *Wertpapiere*. */ +async function kaufErfassen(page, datum, stueck, kurs, neuesPapier) { + await page.goto('/wertpapiere/buchungen/neu') + await page.selectOption('#konto_id', { label: DEPOT }) + await page.selectOption('#vorgang', 'kauf') + await page.fill('#datum', datum) + + if (neuesPapier) { + await page.fill('#wertpapier_name', PAPIER) + await page.fill('#isin', ISIN) + } else { + await page.selectOption('#wertpapier_id', { label: `${PAPIER} (${ISIN})` }) + } + + await page.fill('#stueck', stueck) + await page.fill('#kurs', kurs) + await page.selectOption('#verrechnungskonto_id', { label: GIRO }) + await page.click('.formular button[type="submit"]') + await expect(page.locator('.meldung-erfolg')).toBeVisible() +} + +test.describe('Detailseite eines Wertpapiers', () => { + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage() + await anmelden(page) + + await kontoAnlegen(page, GIRO, 'bank', '10.000,00') + await kontoAnlegen(page, DEPOT, 'depot', '0,00') + + // Zwei Käufe in zwei Jahren – der Zeitraumfilter muss sie trennen. + await kaufErfassen(page, VORJAHR, '4', '50', true) + await kaufErfassen(page, DIESES_JAHR, '6', '100', false) + + // Ohne Kurs bliebe die Bewertung offen; der Kurs kommt von Hand. Der + // Name steht in der Stammdatenliste in einem Eingabefeld und nicht als + // Text – die Zeile wird deshalb über den Feldwert gesucht. + await page.goto('/wertpapiere/stammdaten') + const zeile = page.locator('tr').filter({ has: page.locator(`input[value="${PAPIER}"]`) }) + await zeile.locator('input[name="kurs"]').fill('120') + await zeile.getByRole('button', { name: 'Setzen' }).click() + await expect(page.locator('.meldung-erfolg')).toBeVisible() + + await page.close() + }) + + test.beforeEach(async ({ page }) => { + await anmelden(page) + }) + + test('Der Name in der Übersicht führt zu den Details', async ({ page }) => { + await page.goto('/wertpapiere') + await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click() + + await expect(page).toHaveURL(/\/wertpapiere\/papier\/\d+/) + await expect(page.locator('h1')).toHaveText(PAPIER) + + // Stammdaten und Kurs stehen in je einer Kachel. + await expect(page.locator('.kachel', { hasText: 'Stammdaten' }).locator('.merkmale')) + .toContainText(ISIN) + await expect(page.locator('.kachel', { hasText: 'Letzter Kurs' }).locator('.merkmale')) + .toContainText('120,00') + + // Zehn Stück für 800 €, bewertet mit 1.200,00 €. + const kennzahlen = page.locator('.kennzahlenleiste') + await expect(kennzahlen).toContainText('10') + await expect(kennzahlen).toContainText('1.200,00') + await expect(kennzahlen).toContainText('800,00') + + // Der Bestand steht je Depot – hier liegt alles in einem. + const bestand = page.locator('.kachel', { hasText: 'Bestand je Depot' }) + await expect(bestand).toContainText(DEPOT) + await expect(bestand).toContainText('800,00') + }) + + test('Die Buchungen folgen dem gewählten Zeitraum', async ({ page }) => { + await page.goto('/wertpapiere') + await page.locator('tr', { hasText: PAPIER }).getByRole('link', { name: PAPIER }).click() + + const liste = page.locator('table.liste').last() + + // Voreingestellt ist das laufende Jahr: nur der Kauf von diesem Jahr. + await expect(page.locator('#zeitraum')).toHaveValue('aktuelles_jahr') + await expect(liste).toContainText(`04.03.${JAHR}`) + await expect(liste).not.toContainText(`04.03.${JAHR - 1}`) + + await page.selectOption('#zeitraum', 'letztes_jahr') + await page.click('.filterleiste button[type="submit"]') + + await expect(page.locator('table.liste').last()).toContainText(`04.03.${JAHR - 1}`) + await expect(page.locator('table.liste').last()).not.toContainText(`04.03.${JAHR}`) + + // Die Kennzahlen oben gelten weiter für die ganze Historie. + await expect(page.locator('.kennzahlenleiste')).toContainText('800,00') + }) +})