From be3ee6400eda83b0f2dadc1456b65099b8810d06 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 15:08:01 +0200 Subject: [PATCH 1/2] Detailseite eines Wertpapiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Name in Bestandsübersicht, Depotbuchungen und Stammdaten führt jetzt auf /wertpapiere/papier/{id}: Kennzahlen über die ganze Historie, Stammdaten und Kurs, Bestand je Depot, Wertentwicklung und die Buchungen eines Zeitraums. Depot::bestand() und Depot::entwicklung() nehmen dafür eine Wertpapiernummer entgegen – die Detailseite rechnet nicht selbst, sondern bekommt dieselben Zahlen wie die Übersicht. Neu sind Wertpapier::kurseckwerte() und Depotbuchung::datumsgrenzen(). Co-Authored-By: Claude Opus 5 --- Changes.md | 30 ++ README.md | 4 +- docs/wertpapiere.md | 31 ++ public/index.php | 1 + public/static/app.css | 18 ++ src/Controllers/WertpapiereController.php | 48 ++++ src/Models/Depotbuchung.php | 28 ++ src/Models/Wertpapier.php | 26 ++ src/Services/Depot.php | 30 +- src/View.php | 10 + src/Views/wertpapiere/auswertung.php | 4 +- src/Views/wertpapiere/buchungen.php | 7 +- src/Views/wertpapiere/index.php | 6 +- src/Views/wertpapiere/papier.php | 334 ++++++++++++++++++++++ src/Views/wertpapiere/stammdaten.php | 3 +- tests/26_WertpapierDetailTest.php | 270 +++++++++++++++++ tests/e2e/specs/wertpapierdetail.spec.js | 146 ++++++++++ 17 files changed, 979 insertions(+), 17 deletions(-) create mode 100644 src/Views/wertpapiere/papier.php create mode 100644 tests/26_WertpapierDetailTest.php create mode 100644 tests/e2e/specs/wertpapierdetail.spec.js diff --git a/Changes.md b/Changes.md index 2923071..74aa6cf 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,35 @@ # Änderungen +## 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..0cfaf2a 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 @@ -265,6 +289,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/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/wertpapiere/auswertung.php b/src/Views/wertpapiere/auswertung.php index 5ec35a8..f51f02f 100644 --- a/src/Views/wertpapiere/auswertung.php +++ b/src/Views/wertpapiere/auswertung.php @@ -112,9 +112,7 @@ include __DIR__ . '/../layout/kopf.php'; - - - + diff --git a/src/Views/wertpapiere/buchungen.php b/src/Views/wertpapiere/buchungen.php index 5ca0a3c..3a0edc2 100644 --- a/src/Views/wertpapiere/buchungen.php +++ b/src/Views/wertpapiere/buchungen.php @@ -114,7 +114,9 @@ include __DIR__ . '/../layout/kopf.php'; - + + + @@ -122,8 +124,7 @@ include __DIR__ . '/../layout/kopf.php'; + ? '–' : View::h(View::stueck($buchung['stueck'])) ?> diff --git a/src/Views/wertpapiere/index.php b/src/Views/wertpapiere/index.php index b2bd14b..512b8dc 100644 --- a/src/Views/wertpapiere/index.php +++ b/src/Views/wertpapiere/index.php @@ -115,13 +115,15 @@ include __DIR__ . '/../layout/kopf.php'; - + + + - + diff --git a/src/Views/wertpapiere/papier.php b/src/Views/wertpapiere/papier.php new file mode 100644 index 0000000..ea9b7b8 --- /dev/null +++ b/src/Views/wertpapiere/papier.php @@ -0,0 +1,334 @@ + $wertpapier + * @var array $bestand + * @var array|null $kurs + * @var array{tief:float, hoch:float, anzahl:int}|null $eckwerte + * @var list $entwicklung + * @var array{von: ?string, bis: ?string} $grenzen + * @var array{vorgabe:string, von:string, bis:string} $zeitraum + * @var list> $buchungen + * @var string $csrfToken + */ + +$id = (int)$wertpapier['id']; + +// Ein Papier kann in mehreren Depots liegen; die Zahlen der Seite sind ihre +// Summe. Der Einstand je Stück wird deshalb neu gemittelt und nicht aus einer +// einzelnen Position übernommen. +$stueck = 0.0; +foreach ($bestand['positionen'] as $position) { + $stueck += (float)$position['stueck']; +} +$einstandJeStueck = $stueck > 0.0 ? round($bestand['einstand'] / $stueck, 4) : null; + +// Was im gewählten Zeitraum geschehen ist – die Liste darunter zeigt es Zeile +// für Zeile, hier steht es in drei Zahlen. +$zugang = ['stueck' => 0.0, 'betrag' => 0.0]; +$abgang = ['stueck' => 0.0, 'betrag' => 0.0]; +$ertrag = 0.0; +foreach ($buchungen as $buchung) { + $betrag = (float)$buchung['betrag']; + $kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern']; + + if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) { + $zugang['stueck'] += abs((float)$buchung['stueck']); + $zugang['betrag'] += $betrag + $kosten; + } elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) { + $abgang['stueck'] += abs((float)$buchung['stueck']); + $abgang['betrag'] += $betrag - $kosten; + } elseif ((string)$buchung['vorgang'] === 'dividende') { + $ertrag += $betrag - $kosten; + } +} + +include __DIR__ . '/../layout/kopf.php'; +?> + + + +
+ + + + · + + + · inaktiv + + + Alle Depotbuchungen + Stammdaten bearbeiten + Depotbuchung erfassen +
+ +
+
+ Bestand + +
+
+ Aktueller Wert + +
+
+ Einstandswert + +
+
+ Nicht realisiert + + + +
+
+ Realisiert + + + +
+
+ Erträge + +
+
+ +
+
+

Stammdaten

+
+
ISIN
+
+
WKN
+
+
Art
+
+
Währung
+
+
Kürzel
+
+
Status
+
+ +
Notiz
+
+ +
Erste Buchung
+
+
Letzte Buchung
+
+
+
+ +
+
+

Kurs

+

Hoch und Tief der letzten zwölf Monate, soweit Kurse vorliegen.

+
+
+
Letzter Kurs
+
+ + kein Kurs + + + + · + + +
+
Höchstkurs
+
+
Tiefstkurs
+
+
Bekannte Kurse
+
+
Einstand je Stück
+
+ +
+
+
+
+ +
+
+

Bestand je Depot

+
+ +

+ Kein Bestand – das Papier ist verkauft oder war nie im Depot. Realisierte + Gewinne und Erträge zählen oben weiter mit. +

+ + + + + + + + + + + + + + + + + + + + + + + + + 1): ?> + + + + + + + + + + + +
DepotStückEinstand je StückEinstandWertGewinn
+ + + + + +
Summe + +
+ +

+ Für dieses Papier fehlt ein Kurs – Wert und nicht realisierter Gewinn bleiben + offen. Einzutragen unter Wertpapiere. +

+ + +
+ + 0.0; +} +?> + +
+
+

Wertentwicklung

+

Bewerteter Bestand je Monatsende, dazu der Einstand.

+
+ +
+ + +

Buchungen

+ +
+ + + + + + + + + +
+ +

+ + +

Keine Buchungen in diesem Zeitraum.

+ + + + + + + + + + + + + + + + + + + + + + + + +
DatumVorgangDepotStückKursBetragKostenVerrechnung
+ + + + + + + + + + + 0.0 ? View::betrag($kosten) : '–' ?> + + + + + + + – + +
+ +

+ Im Zeitraum: + Zugänge Stück + für · + Abgänge Stück + für · + Erträge . + Zugänge samt Nebenkosten, Abgänge und Erträge abzüglich – so, wie das Geld + geflossen ist. +

+ + + diff --git a/src/Views/wertpapiere/stammdaten.php b/src/Views/wertpapiere/stammdaten.php index 8327be3..51d56af 100644 --- a/src/Views/wertpapiere/stammdaten.php +++ b/src/Views/wertpapiere/stammdaten.php @@ -105,7 +105,8 @@ include __DIR__ . '/../layout/kopf.php'; > - + + 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/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') + }) +}) From eb08cd6b83dcfb9288017b69b17de5fdd8edcff0 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Thu, 13 Aug 2026 15:08:27 +0200 Subject: [PATCH 2/2] Kursabruf: Yahoo Finanzen als dritter Anbieter, FMP-Adressen berichtigt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 – 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 --- Aktienkurse.md | 38 ++++- Changes.md | 39 +++++ docs/wertpapiere.md | 34 ++++- src/Services/Kursabruf.php | 244 +++++++++++++++++++++++++++--- src/Views/einstellungen/index.php | 11 +- tests/27_KursabrufTest.php | 154 +++++++++++++++++++ 6 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 tests/27_KursabrufTest.php 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 74aa6cf..2e23dc7 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,44 @@ # Änderungen +## Kurse von Yahoo Finanzen (13.08.2026) + +Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent. +Eine offene Schnittstelle gibt es dort nicht mehr – `query1.finance.yahoo.com` antwortet +ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb –, gelesen wird deshalb die Kursseite +selbst. + +- Auswahl *Yahoo Finanzen* im Einstellungsdialog unter *Wertpapiere*; das Feld für den + API-Schlüssel bleibt dort leer. +- Das **Kürzel muss in den Stammdaten stehen** und den Handelsplatz nennen (`ALV.DE`, + `PTX.F`). Yahoo hat keine ISIN-Suche mehr, `/lookup` und `/search` antworten mit 404. +- `Kursabruf::yahooKurs()` liest beide Ausprägungen der Seite (`` 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 – diff --git a/docs/wertpapiere.md b/docs/wertpapiere.md index 0cfaf2a..7e1ca45 100644 --- a/docs/wertpapiere.md +++ b/docs/wertpapiere.md @@ -151,15 +151,37 @@ Ersatzwert wäre bequem, verstellte aber den Blick. ### Kursabruf -`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`): +`Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`): * **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt -* **Financial Modeling Prep** – 250 Abrufe/Tag +* **Financial Modeling Prep** – 250 Abrufe/Tag, im Gratistarif ohne ISIN-Suche und ohne + deutsche Börsenplätze (beides antwortet mit HTTP 402) +* **Yahoo Finanzen** – kein Schlüssel, kein Kontingent, dafür muss das Kürzel in den + Stammdaten stehen -Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird -am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den -Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch -kein Kurs vorliegt. +Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene +Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das +halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für +heute noch kein Kurs vorliegt. + +**Yahoo Finanzen** hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com` +antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb, und `/lookup` wie `/search` +sind abgeschaltet. Gelesen wird deshalb die Kursseite `de.finance.yahoo.com/quote//`. + +* 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: 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/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.