Merge pull request 'Wertpapier-Detailseite und Kursabruf über Yahoo Finanzen' (#15) from fds2610-bot/Finanzmanager:main into main

Reviewed-on: https://codeberg.org/fds2610/Finanzmanager/pulls/15
This commit is contained in:
fds2610 2026-08-13 15:32:47 +02:00
commit b7dc49dee5
21 changed files with 1465 additions and 51 deletions

View file

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

View file

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

View file

@ -32,7 +32,9 @@ Haushalt wird beides parallel genutzt.
übernehmen. Eine abgerufene Wertpapierabrechnung wird im Kontoblatt zum Geschäft: Das ü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 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 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 * **Nebenmodule** – Buchungsvorlagen, regelmäßige Buchungen mit Fälligkeitslauf und
Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan. Finanzkalender, Erinnerungen, Adressen, Verträge, Sparziele, Kredite mit Tilgungsplan.
* **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS. * **Zahlungsverkehr** – Überweisungen und Terminüberweisungen über FinTS.

View file

@ -116,6 +116,30 @@ Einstand.
Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter
Gewinn zählt weiter mit. 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 ## Kurse
Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und 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 ### Kursabruf
`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`): `Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`):
* **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt * **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt
* **Financial Modeling Prep** – 250 Abrufe/Tag * **Financial Modeling Prep** – 250 Abrufe/Tag, im Gratistarif ohne ISIN-Suche und ohne
deutsche Börsenplätze (beides antwortet mit HTTP 402)
* **Yahoo Finanzen** – kein Schlüssel, kein Kontingent, dafür muss das Kürzel in den
Stammdaten stehen
Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene
am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das
Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für
kein Kurs vorliegt. heute noch kein Kurs vorliegt.
**Yahoo Finanzen** hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com`
antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb, und `/lookup` wie `/search`
sind abgeschaltet. Gelesen wird deshalb die Kursseite `de.finance.yahoo.com/quote/<Kürzel>/`.
* Das **Kürzel** muss den Handelsplatz nennen – `ALV.DE` für Xetra, `PTX.F` für Frankfurt.
Ohne Kürzel meldet der Abruf das ausdrücklich, denn raten lässt sich hier nichts.
* Yahoo liefert die Seite in zwei Ausprägungen aus: mal mit `<fin-streamer>` samt
`data-value`, mal nur mit dem angezeigten, deutsch formatierten Kurs. `yahooKurs()` liest
beides und prüft am Seitentitel, dass die Seite zum Kürzel gehört – sonst käme bei einem
Tippfehler der Kurs eines fremden Papiers in den Bestand.
* Zwischen zwei Seiten liegt **eine Sekunde**; auf schnelle Serienabrufe antwortet Yahoo
mit HTTP 503. Dieser 503 ist vorübergehend – er trifft auch Kürzel, die im Browser zur
selben Zeit funktionieren –, deshalb bis zu drei Versuche je Papier.
* Der angezeigte Kursstand („Markt geöffnet“, „Bei Börsenschluss“) geht ins Debug-Log:
Über sein Auslieferungsnetz gibt Yahoo gelegentlich eine ältere Fassung der Seite heraus.
Weil hier fremdes Markup gelesen wird, ist dieser Weg der empfindlichste der drei – ein
Umbau bei Yahoo fällt als Fehler auf, nicht als falscher Kurs.
Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf Anbieter und Schlüssel stehen im Einstellungsdialog unter *Wertpapiere*. Der tägliche Lauf
gehört in einen Cron-Eintrag: gehört in einen Cron-Eintrag:
@ -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 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`. 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 `tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des
echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen, echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen,
Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten

View file

@ -247,6 +247,7 @@ $router->post('/zahlungsverkehr/{id}/loeschen', fn(array $p) => (new Zahlungsver
// ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ────────────── // ── Wertpapiere: Bestände, Depotbuchungen, Kurse, Auswertungen ──────────────
$router->get('/wertpapiere', fn() => (new WertpapiereController())->index()); $router->get('/wertpapiere', fn() => (new WertpapiereController())->index());
$router->get('/wertpapiere/auswertung', fn() => (new WertpapiereController())->auswertung()); $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', fn() => (new WertpapiereController())->buchungen());
$router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu()); $router->get('/wertpapiere/buchungen/neu', fn() => (new WertpapiereController())->neu());

View file

@ -1721,6 +1721,24 @@ table.bericht .wertlink:hover {
margin: 0; 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 /* Zahlenlastige Tabellen bekommen Ziffernschrift in den rechtsbündigen
Spalten – so stehen Kommastellen untereinander. */ Spalten – so stehen Kommastellen untereinander. */
table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); } table.liste.zahlen td.rechts { font-family: var(--schrift-zahl); }

View file

@ -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<string,string> $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 public function buchungen(): void
{ {
$this->verlangeAnmeldung(); $this->verlangeAnmeldung();

View file

@ -116,6 +116,34 @@ class Depotbuchung
return Database::fetchAll($sql . ' ORDER BY d.datum DESC, d.id DESC', $params); 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 * Die Depotbuchung zu einer Geldbuchung – oder null, wenn die Buchung kein
* Wertpapiergeschäft ist. * Wertpapiergeschäft ist.

View file

@ -262,6 +262,32 @@ class Wertpapier
return $kurse; 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<array<string,mixed>> */ /** @return list<array<string,mixed>> */
public static function kursverlauf(int $wertpapierId, int $tage = 365): array public static function kursverlauf(int $wertpapierId, int $tage = 365): array
{ {

View file

@ -29,6 +29,10 @@ class Depot
/** /**
* Die Bestände eines Depots (oder aller Depots) zum Stichtag. * 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{ * @return array{
* positionen: list<array<string,mixed>>, * positionen: list<array<string,mixed>>,
* einstand: float, * einstand: float,
@ -39,8 +43,11 @@ class Depot
* bewertbar: bool * 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'); $stichtag ??= date('Y-m-d');
$kurse = Wertpapier::letzteKurse($stichtag); $kurse = Wertpapier::letzteKurse($stichtag);
@ -48,7 +55,7 @@ class Depot
$realisiert = 0.0; $realisiert = 0.0;
$ertraege = 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']; $schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
$position = $positionen[$schluessel] ?? [ $position = $positionen[$schluessel] ?? [
@ -283,12 +290,16 @@ class Depot
* *
* @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}> * @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}>
*/ */
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 = []; $reihe = [];
foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) { foreach (Vermoegensentwicklung::monate($monate, $heute) as $monat) {
$bestand = self::bestand($kontoId, $monat['bis']); $bestand = self::bestand($kontoId, $monat['bis'], $wertpapierId);
$reihe[] = [ $reihe[] = [
'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']), 'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']),
@ -307,7 +318,7 @@ class Depot
* *
* @return list<array<string,mixed>> * @return list<array<string,mixed>>
*/ */
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]; $params = [Mandant::id(), $bis];
$sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot $sql = 'SELECT d.*, w.name AS wertpapier, w.isin, w.wkn, k.name AS depot
@ -321,6 +332,11 @@ class Depot
$params[] = $kontoId; $params[] = $kontoId;
} }
if ($wertpapierId !== null) {
$sql .= ' AND d.wertpapier_id = ?';
$params[] = $wertpapierId;
}
return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params); return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params);
} }
} }

View file

@ -10,9 +10,10 @@ use Finanzmanager\Models\Wertpapier;
/** /**
* Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab. * Holt Tageskurse von einem Kursdienst und legt sie in `kurse` ab.
* *
* Zwei Anbieter, beide mit kostenlosem Kontingent (siehe `Aktienkurse.md`): * Drei Anbieter (siehe `Aktienkurse.md`): **Leeway.tech** (50 Abrufe/Tag, kennt
* **Leeway.tech** (50 Abrufe/Tag, kennt ISINs direkt) und **Financial Modeling * ISINs direkt), **Financial Modeling Prep** (250/Tag, im Gratistarif ohne
* Prep** (250/Tag). Bei rund zwanzig Positionen reicht beides. * ISIN-Suche und ohne deutsche Börsenplätze) und **Yahoo Finanzen**, das ohne
* Schlüssel auskommt, dafür aber das Kürzel in den Stammdaten braucht.
* *
* Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs * Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam. * oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
@ -26,11 +27,30 @@ class Kursabruf
public const ANBIETER = [ public const ANBIETER = [
'leeway' => 'Leeway.tech (50 Abrufe/Tag)', 'leeway' => 'Leeway.tech (50 Abrufe/Tag)',
'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)', 'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)',
'yahoo' => 'Yahoo Finanzen (ohne Schlüssel, Kürzel nötig)',
]; ];
/** Anbieter, die ohne API-Schlüssel auskommen. */
private const OHNE_SCHLUESSEL = ['yahoo'];
/** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */ /** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */
private const ZEITGRENZE = 15; private const ZEITGRENZE = 15;
/** Mindestabstand zwischen zwei Kursseiten, in Sekunden. */
private const SEITENPAUSE = 1.0;
/** Wie oft eine Kursseite geholt wird, wenn der Anbieter kurz streikt. */
private const SEITENVERSUCHE = 3;
/** Wartezeit vor dem nächsten Versuch, in Sekunden. */
private const WIEDERHOLPAUSE = 2;
/** Zeitpunkt der zuletzt geholten Kursseite. */
private static float $letzteSeite = 0.0;
/** Statuscode der letzten Antwort – für die Frage, ob ein zweiter Versuch lohnt. */
private static int $letzterStatus = 0;
/** /**
* Holt Kurse für alle Wertpapiere mit Bestand. * Holt Kurse für alle Wertpapiere mit Bestand.
* *
@ -50,7 +70,7 @@ class Kursabruf
if (!array_key_exists($anbieter, self::ANBIETER)) { if (!array_key_exists($anbieter, self::ANBIETER)) {
return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]); return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]);
} }
if ($key === '') { if ($key === '' && !in_array($anbieter, self::OHNE_SCHLUESSEL, true)) {
return self::ergebnis(0, 1, 0, [ return self::ergebnis(0, 1, 0, [
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).', 'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
]); ]);
@ -127,6 +147,7 @@ class Kursabruf
{ {
return match ($anbieter) { return match ($anbieter) {
'fmp' => self::fmp($key, $wertpapier), 'fmp' => self::fmp($key, $wertpapier),
'yahoo' => self::yahoo($wertpapier),
default => self::leeway($key, $wertpapier), default => self::leeway($key, $wertpapier),
}; };
} }
@ -174,6 +195,9 @@ class Kursabruf
* Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt * Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt
* der Kurs über die Kurzabfrage. * der Kurs über die Kurzabfrage.
* *
* Seit dem 31. August 2025 beantwortet der Dienst die alten `/api/v3/`-
* Adressen nur noch mit HTTP 403; gültig ist allein `/stable/`.
*
* @param array<string,mixed> $wertpapier * @param array<string,mixed> $wertpapier
*/ */
private static function fmp(string $key, array $wertpapier): ?float private static function fmp(string $key, array $wertpapier): ?float
@ -187,8 +211,8 @@ class Kursabruf
} }
$treffer = self::hole( $treffer = self::hole(
'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin) 'https://financialmodelingprep.com/stable/search-isin?isin=' . rawurlencode($isin)
. '&limit=1&apikey=' . rawurlencode($key) . '&apikey=' . rawurlencode($key)
); );
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']); $symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
@ -200,19 +224,160 @@ class Kursabruf
} }
$antwort = self::hole( $antwort = self::hole(
'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol) 'https://financialmodelingprep.com/stable/quote?symbol=' . rawurlencode($symbol)
. '?apikey=' . rawurlencode($key) . '&apikey=' . rawurlencode($key)
); );
return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price'])); return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price']));
} }
/**
* Yahoo Finanzen hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com`
* antwortet auf Anfragen ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb.
* Übrig bleibt die Kursseite selbst, aus der der Kurs gelesen wird.
*
* Der Preis dafür ist Abhängigkeit von fremdem Markup – deshalb zwei Muster
* und ein Abgleich mit dem Seitentitel, damit ein Umbau bei Yahoo als Fehler
* auffällt und nicht als falscher Kurs in den Bestand wandert.
*
* Eine ISIN-Suche gibt es dort nicht mehr (`/lookup` und `/search` antworten
* mit 404), das Kürzel muss deshalb in den Stammdaten stehen.
*
* @param array<string,mixed> $wertpapier
*/
private static function yahoo(array $wertpapier): ?float
{
$symbol = trim((string)$wertpapier['symbol']);
if ($symbol === '') {
throw new \RuntimeException(
'Yahoo Finanzen kennt keine ISIN-Suche – bitte das Kürzel in den '
. 'Stammdaten hinterlegen (Xetra-Werte enden auf .DE, etwa ALV.DE).'
);
}
$html = self::holeSeite('https://de.finance.yahoo.com/quote/' . rawurlencode($symbol) . '/');
// Yahoo liefert über sein Auslieferungsnetz gelegentlich eine ältere
// Fassung der Seite. Der angezeigte Stand („Markt geöffnet“, „Bei
// Börsenschluss“) landet deshalb im Protokoll – daran lässt sich ein
// alter Kurs erkennen, wenn einer auffällt.
if (preg_match('/marketTimeNotice[^>]*>(.*?)<\/div>/s', $html, $stand) === 1) {
DebugLog::schreibe(
'kursabruf',
$symbol . ': ' . trim((string)preg_replace('/\s+/', ' ', strip_tags($stand[1])))
);
}
return self::yahooKurs($html, $symbol);
}
/**
* Der Kurs aus einer Yahoo-Kursseite. Getrennt vom Abruf, damit die
* Auswertung ohne Netz geprüft werden kann.
*
* Yahoo liefert die Seite in zwei Ausprägungen aus – mal mit dem
* maschinenlesbaren `<fin-streamer>` (Punkt als Dezimaltrenner), mal nur mit
* dem angezeigten Kurs (deutsche Schreibweise). Beides kommt vor, teils am
* selben Tag für verschiedene Papiere.
*/
public static function yahooKurs(string $html, string $symbol): ?float
{
// Der Titel lautet „Allianz SE (ALV.DE) Aktienkurs …“. Steht dort ein
// anderes Kürzel, zeigt die Seite ein anderes Papier – dann lieber kein
// Kurs als der falsche.
if (preg_match('#<title>(.*?)</title>#is', $html, $titel) === 1
&& !str_contains($titel[1], '(' . $symbol . ')')
) {
throw new \RuntimeException(
'Yahoo zeigt unter dem Kürzel ' . $symbol . ' eine andere Seite an.'
);
}
$streamer = '/<fin-streamer[^>]*data-symbol="' . preg_quote($symbol, '/') . '"'
. '[^>]*data-field="regularMarketPrice"[^>]*data-value="([0-9.]+)"/i';
if (preg_match($streamer, $html, $treffer) === 1) {
return self::zahl($treffer[1]);
}
if (preg_match('/data-testid="qsp-price"[^>]*>([^<]+)</i', $html, $treffer) === 1) {
return self::zahl(self::deutscheZahl($treffer[1]));
}
return null;
}
/**
* „1.234,56“ wird zu 1234.56. Ohne Komma bleibt die Zahl, wie sie ist –
* Yahoo liefert je nach Ausprägung der Seite die eine oder die andere
* Schreibweise.
*/
private static function deutscheZahl(string $wert): string
{
$wert = trim(str_replace(["\u{00A0}", '&nbsp;', ' '], '', $wert));
if (str_contains($wert, ',')) {
$wert = str_replace(['.', ','], ['', '.'], $wert);
}
return $wert;
}
/** /**
* Ein GET gegen den Kursdienst, Antwort als Feld. * Ein GET gegen den Kursdienst, Antwort als Feld.
* *
* @return array<mixed> * @return array<mixed>
*/ */
private static function hole(string $url): array private static function hole(string $url): array
{
$antwort = self::abruf($url, 'application/json', false);
$daten = json_decode($antwort, true);
if (!is_array($daten)) {
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
}
return $daten;
}
/**
* Eine Kursseite als HTML. Yahoo leitet von `/quote/X/` auf die Länderseite
* weiter und liefert komprimiert – beides muss curl mitmachen.
*
* Zwischen zwei Seiten liegt eine Sekunde: Auf schnelle Serienabrufe
* antwortet Yahoo mit HTTP 503. Bei einer Handvoll Papiere am Tag fällt die
* Wartezeit nicht ins Gewicht.
*
* Ein 503 ist bei Yahoo vorübergehend und trifft auch Kürzel, die kurz
* darauf wieder ausgeliefert werden – deshalb bis zu drei Versuche, statt
* das Papier für den Tag verloren zu geben.
*/
private static function holeSeite(string $url): string
{
for ($versuch = 1; ; $versuch++) {
$wartezeit = self::SEITENPAUSE - (microtime(true) - self::$letzteSeite);
if (self::$letzteSeite > 0.0 && $wartezeit > 0.0) {
usleep((int)($wartezeit * 1_000_000));
}
self::$letzteSeite = microtime(true);
try {
return self::abruf($url, 'text/html', true);
} catch (\RuntimeException $fehler) {
if (self::$letzterStatus !== 503 || $versuch >= self::SEITENVERSUCHE) {
throw $fehler;
}
sleep(self::WIEDERHOLPAUSE);
}
}
}
/**
* Ein GET gegen den Kursdienst, Rohtext der Antwort.
*/
private static function abruf(string $url, string $accept, bool $folgen): string
{ {
$kanal = curl_init($url); $kanal = curl_init($url);
if ($kanal === false) { if ($kanal === false) {
@ -223,9 +388,11 @@ class Kursabruf
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::ZEITGRENZE, CURLOPT_TIMEOUT => self::ZEITGRENZE,
CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_FOLLOWLOCATION => false, CURLOPT_FOLLOWLOCATION => $folgen,
CURLOPT_MAXREDIRS => 3,
CURLOPT_ENCODING => '',
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf', CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
CURLOPT_HTTPHEADER => ['Accept: application/json'], CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
]); ]);
$antwort = curl_exec($kanal); $antwort = curl_exec($kanal);
@ -233,6 +400,8 @@ class Kursabruf
$fehler = curl_error($kanal); $fehler = curl_error($kanal);
curl_close($kanal); curl_close($kanal);
self::$letzterStatus = $status;
// Der Schlüssel steht in der Adresse – er darf nirgends im Klartext // Der Schlüssel steht in der Adresse – er darf nirgends im Klartext
// auftauchen, auch nicht in einer Fehlermeldung. // auftauchen, auch nicht in einer Fehlermeldung.
$sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url; $sichtbar = preg_replace('/(apitoken|apikey)=[^&]*/', '$1=…', $url) ?? $url;
@ -241,22 +410,30 @@ class Kursabruf
if ($antwort === false) { if ($antwort === false) {
throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler); throw new \RuntimeException('Kursdienst nicht erreichbar: ' . $fehler);
} }
if ($status === 401 || $status === 403) {
throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP ' . $status . ').'); // Der Klartext des Dienstes wird mitgegeben: Ein 403 heißt bei Financial
// Modeling Prep längst nicht mehr nur „Schlüssel falsch“, sondern meist
// „diese Adresse gibt es nicht mehr“ – ohne den Text sucht man am
// falschen Ende.
$grund = self::meldung((string)$antwort);
if ($status === 401) {
throw new \RuntimeException('Der API-Schlüssel wurde abgelehnt (HTTP 401).' . $grund);
}
if ($status === 402) {
throw new \RuntimeException('Im gebuchten Tarif nicht enthalten (HTTP 402).' . $grund);
}
if ($status === 403) {
throw new \RuntimeException('Zugriff verweigert (HTTP 403).' . $grund);
} }
if ($status === 429) { if ($status === 429) {
throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.'); throw new \RuntimeException('Das Tageskontingent des Kursdienstes ist aufgebraucht.');
} }
if ($status < 200 || $status >= 300) { if ($status < 200 || $status >= 300) {
throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.'); throw new \RuntimeException('Kursdienst antwortet mit HTTP ' . $status . '.' . $grund);
} }
$daten = json_decode((string)$antwort, true); return (string)$antwort;
if (!is_array($daten)) {
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
}
return $daten;
} }
/** /**
@ -280,6 +457,35 @@ class Kursabruf
return null; return null;
} }
/**
* Die Begründung des Dienstes, gekürzt und einzeilig – als Anhang an die
* eigene Fehlermeldung. Die Anbieter antworten mal mit JSON, mal mit
* schlichtem Text, deshalb beides.
*/
private static function meldung(string $antwort): string
{
// Fehlerseiten der Kursseiten-Anbieter sind ganze HTML-Dokumente – davon
// gehört nichts in eine Fehlermeldung.
if (str_starts_with(ltrim($antwort), '<')) {
return '';
}
$daten = json_decode($antwort, true);
$text = is_array($daten)
? (string)(self::ersterWert($daten, ['Error Message', 'error', 'message', 'Message']) ?? '')
: $antwort;
$text = trim(preg_replace('/\s+/', ' ', $text) ?? '');
if ($text === '') {
return '';
}
if (mb_strlen($text) > 200) {
$text = mb_substr($text, 0, 200) . '…';
}
return ' ' . $text;
}
private static function zahl(mixed $wert): ?float private static function zahl(mixed $wert): ?float
{ {
if ($wert === null || !is_numeric($wert)) { if ($wert === null || !is_numeric($wert)) {

View file

@ -55,6 +55,16 @@ class View
return $mitWaehrung ? $text . "\u{00A0}€" : $text; 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 – * Monatsnamen auf Deutsch. Nicht über date('F') oder IntlDateFormatter –
* date() ist immer englisch und intl ist keine Standarderweiterung. Der * date() ist immer englisch und intl ist keine Standarderweiterung. Der

View file

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

View file

@ -112,9 +112,7 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= View::h($geschaeft['depot']) ?></td> <td><?= View::h($geschaeft['depot']) ?></td>
<td class="rechts"> <td class="rechts"><?= View::h(View::stueck($geschaeft['stueck'])) ?></td>
<?= View::h(rtrim(rtrim(number_format($geschaeft['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td>
<td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td> <td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td>
<td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td> <td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td>
<td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>"> <td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>">

View file

@ -114,7 +114,9 @@ include __DIR__ . '/../layout/kopf.php';
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<?= View::h($buchung['wertpapier']) ?> <a href="/wertpapiere/papier/<?= (int)$buchung['wertpapier_id'] ?>">
<?= View::h($buchung['wertpapier']) ?>
</a>
<?php if ($buchung['isin'] !== ''): ?> <?php if ($buchung['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['isin']) ?></span> <span class="nebentext"><?= View::h($buchung['isin']) ?></span>
<?php endif; ?> <?php endif; ?>
@ -122,8 +124,7 @@ include __DIR__ . '/../layout/kopf.php';
<td><?= View::h($buchung['depot']) ?></td> <td><?= View::h($buchung['depot']) ?></td>
<td class="rechts"> <td class="rechts">
<?= (float)$buchung['stueck'] === 0.0 <?= (float)$buchung['stueck'] === 0.0
? '–' ? '–' : View::h(View::stueck($buchung['stueck'])) ?>
: View::h(rtrim(rtrim(number_format((float)$buchung['stueck'], 6, ',', '.'), '0'), ',')) ?>
</td> </td>
<td class="rechts"> <td class="rechts">
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?> <?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>

View file

@ -115,13 +115,15 @@ include __DIR__ . '/../layout/kopf.php';
<?php foreach ($bestand['positionen'] as $position): ?> <?php foreach ($bestand['positionen'] as $position): ?>
<tr> <tr>
<td> <td>
<?= View::h($position['name']) ?> <a href="/wertpapiere/papier/<?= (int)$position['wertpapier_id'] ?>">
<?= View::h($position['name']) ?>
</a>
<?php if ($position['isin'] !== ''): ?> <?php if ($position['isin'] !== ''): ?>
<span class="nebentext"><?= View::h($position['isin']) ?></span> <span class="nebentext"><?= View::h($position['isin']) ?></span>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= View::h($position['depot']) ?></td> <td><?= View::h($position['depot']) ?></td>
<td class="rechts"><?= View::h(rtrim(rtrim(number_format($position['stueck'], 6, ',', '.'), '0'), ',')) ?></td> <td class="rechts"><?= View::h(View::stueck($position['stueck'])) ?></td>
<td class="rechts"> <td class="rechts">
<?= $position['einstand_je_stueck'] === null <?= $position['einstand_je_stueck'] === null
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?> ? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>

View file

@ -0,0 +1,334 @@
<?php
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Alles zu einem Wertpapier auf einer Seite: oben die Stammdaten, der Kurs und
* die Kennzahlen über die ganze Historie, unten die Buchungen eines Zeitraums.
*
* @var array<string,mixed> $wertpapier
* @var array<string,mixed> $bestand
* @var array<string,mixed>|null $kurs
* @var array{tief:float, hoch:float, anzahl:int}|null $eckwerte
* @var list<array{monat:string, kuerzel:string, wert:float, einstand:float}> $entwicklung
* @var array{von: ?string, bis: ?string} $grenzen
* @var array{vorgabe:string, von:string, bis:string} $zeitraum
* @var list<array<string,mixed>> $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';
?>
<div class="tableiste">
<a href="/wertpapiere" class="tab">Bestände</a>
<a href="/wertpapiere/buchungen" class="tab">Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="tab aktiv">Wertpapiere</a>
<a href="/wertpapiere/auswertung?art=veraeusserungen" class="tab">Veräußerungen</a>
<a href="/wertpapiere/auswertung?art=ertraege" class="tab">Erträge</a>
</div>
<div class="seitenaktionen">
<span class="nebentext">
<?= View::h(Wertpapier::ARTEN[$wertpapier['art']] ?? $wertpapier['art']) ?>
<?php if ((string)$wertpapier['isin'] !== ''): ?>
· <?= View::h($wertpapier['isin']) ?>
<?php endif; ?>
<?php if ((int)$wertpapier['aktiv'] !== 1): ?>
· <span class="marke">inaktiv</span>
<?php endif; ?>
</span>
<a href="/wertpapiere/buchungen?wertpapier=<?= $id ?>" class="knopf">Alle Depotbuchungen</a>
<a href="/wertpapiere/stammdaten" class="knopf">Stammdaten bearbeiten</a>
<a href="/wertpapiere/buchungen/neu" class="knopf primaer">Depotbuchung erfassen</a>
</div>
<div class="kennzahlenleiste">
<div class="kennzahlkachel">
<span class="kennzahltitel">Bestand</span>
<span class="kennzahl"><?= View::h(View::stueck($stueck)) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Aktueller Wert</span>
<span class="kennzahl"><?= $bestand['bewertbar'] ? View::betrag($bestand['wert']) : '–' ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Einstandswert</span>
<span class="kennzahl"><?= View::betrag($bestand['einstand']) ?></span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Nicht realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= $bestand['bewertbar'] ? View::betrag($bestand['gewinn']) : '–' ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Realisiert</span>
<span class="kennzahl <?= View::betragKlasse($bestand['realisiert']) ?>">
<?= View::betrag($bestand['realisiert']) ?>
</span>
</div>
<div class="kennzahlkachel">
<span class="kennzahltitel">Erträge</span>
<span class="kennzahl"><?= View::betrag($bestand['ertraege']) ?></span>
</div>
</div>
<div class="kachelraster">
<section class="kachel">
<header><h2>Stammdaten</h2></header>
<dl class="merkmale">
<dt>ISIN</dt>
<dd><?= View::h($wertpapier['isin'] ?: '–') ?></dd>
<dt>WKN</dt>
<dd><?= View::h($wertpapier['wkn'] ?: '–') ?></dd>
<dt>Art</dt>
<dd><?= View::h(Wertpapier::ARTEN[$wertpapier['art']] ?? $wertpapier['art']) ?></dd>
<dt>Währung</dt>
<dd><?= View::h($wertpapier['waehrung']) ?></dd>
<dt>Kürzel</dt>
<dd><?= View::h($wertpapier['symbol'] ?: '–') ?></dd>
<dt>Status</dt>
<dd><?= (int)$wertpapier['aktiv'] === 1 ? 'aktiv' : 'inaktiv' ?></dd>
<?php if ((string)$wertpapier['notiz'] !== ''): ?>
<dt>Notiz</dt>
<dd><?= View::h($wertpapier['notiz']) ?></dd>
<?php endif; ?>
<dt>Erste Buchung</dt>
<dd><?= View::h(View::datum($grenzen['von'])) ?></dd>
<dt>Letzte Buchung</dt>
<dd><?= View::h(View::datum($grenzen['bis'])) ?></dd>
</dl>
</section>
<section class="kachel">
<header>
<h2>Kurs</h2>
<p class="kachelhinweis">Hoch und Tief der letzten zwölf Monate, soweit Kurse vorliegen.</p>
</header>
<dl class="merkmale">
<dt>Letzter Kurs</dt>
<dd>
<?php if ($kurs === null): ?>
<span class="nebentext">kein Kurs</span>
<?php else: ?>
<?= View::h(number_format((float)$kurs['kurs'], 2, ',', '.')) ?>
<span class="nebentext">
<?= View::h(View::datum($kurs['datum'])) ?> · <?= View::h($kurs['quelle']) ?>
</span>
<?php endif; ?>
</dd>
<dt>Höchstkurs</dt>
<dd><?= $eckwerte === null ? '–' : View::h(number_format($eckwerte['hoch'], 2, ',', '.')) ?></dd>
<dt>Tiefstkurs</dt>
<dd><?= $eckwerte === null ? '–' : View::h(number_format($eckwerte['tief'], 2, ',', '.')) ?></dd>
<dt>Bekannte Kurse</dt>
<dd><?= $eckwerte === null ? '0' : (int)$eckwerte['anzahl'] ?></dd>
<dt>Einstand je Stück</dt>
<dd>
<?= $einstandJeStueck === null
? '–' : View::h(number_format($einstandJeStueck, 2, ',', '.')) ?>
</dd>
</dl>
</section>
</div>
<section class="kachel">
<header>
<h2>Bestand je Depot</h2>
</header>
<?php if ($bestand['positionen'] === []): ?>
<p class="platzhalter">
Kein Bestand – das Papier ist verkauft oder war nie im Depot. Realisierte
Gewinne und Erträge zählen oben weiter mit.
</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Depot</th>
<th class="rechts">Stück</th>
<th class="rechts">Einstand je Stück</th>
<th class="rechts">Einstand</th>
<th class="rechts">Wert</th>
<th class="rechts">Gewinn</th>
</tr>
</thead>
<tbody>
<?php foreach ($bestand['positionen'] as $position): ?>
<tr>
<td><?= View::h($position['depot']) ?></td>
<td class="rechts"><?= View::h(View::stueck($position['stueck'])) ?></td>
<td class="rechts">
<?= $position['einstand_je_stueck'] === null
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>
</td>
<td class="rechts"><?= View::betrag($position['einstand']) ?></td>
<td class="rechts">
<?= $position['wert'] === null ? '–' : View::betrag($position['wert']) ?>
</td>
<td class="rechts <?= $position['gewinn'] === null ? '' : View::betragKlasse($position['gewinn']) ?>">
<?= $position['gewinn'] === null ? '–' : View::betrag($position['gewinn']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<?php if (count($bestand['positionen']) > 1): ?>
<tfoot>
<tr class="summenzeile">
<td>Summe</td>
<td class="rechts"><?= View::h(View::stueck($stueck)) ?></td>
<td></td>
<td class="rechts"><?= View::betrag($bestand['einstand']) ?></td>
<td class="rechts"><?= View::betrag($bestand['wert']) ?></td>
<td class="rechts <?= View::betragKlasse($bestand['gewinn']) ?>">
<?= View::betrag($bestand['gewinn']) ?>
</td>
</tr>
</tfoot>
<?php endif; ?>
</table>
<?php if (!$bestand['bewertbar']): ?>
<p class="hinweis klein">
Für dieses Papier fehlt ein Kurs – Wert und nicht realisierter Gewinn bleiben
offen. Einzutragen unter <a href="/wertpapiere/stammdaten">Wertpapiere</a>.
</p>
<?php endif; ?>
<?php endif; ?>
</section>
<?php
// Ohne Kurshistorie stünde die Linie auf null – dann lieber gar kein Diagramm.
$hatEntwicklung = false;
foreach ($entwicklung as $punkt) {
$hatEntwicklung = $hatEntwicklung || $punkt['wert'] > 0.0;
}
?>
<?php if ($hatEntwicklung): ?>
<section class="kachel">
<header>
<h2>Wertentwicklung</h2>
<p class="kachelhinweis">Bewerteter Bestand je Monatsende, dazu der Einstand.</p>
</header>
<?php $reihe = $entwicklung; include __DIR__ . '/entwicklungsdiagramm.php'; ?>
</section>
<?php endif; ?>
<h2>Buchungen</h2>
<form method="get" action="/wertpapiere/papier/<?= $id ?>" class="filterleiste">
<label for="zeitraum">Zeitraum</label>
<select id="zeitraum" name="zeitraum">
<?php foreach (Zeitraum::VORGABEN as $schluessel => $name): ?>
<option value="<?= View::h($schluessel) ?>" <?= $zeitraum['vorgabe'] === $schluessel ? 'selected' : '' ?>>
<?= View::h($name) ?>
</option>
<?php endforeach; ?>
</select>
<label for="von">von</label>
<input type="date" id="von" name="von" value="<?= View::h($zeitraum['von']) ?>">
<label for="bis">bis</label>
<input type="date" id="bis" name="bis" value="<?= View::h($zeitraum['bis']) ?>">
<button type="submit" class="knopf primaer">Anzeigen</button>
</form>
<p class="berichtskopf"><?= View::h(Zeitraum::beschriftung($zeitraum['von'], $zeitraum['bis'])) ?></p>
<?php if ($buchungen === []): ?>
<p class="platzhalter">Keine Buchungen in diesem Zeitraum.</p>
<?php else: ?>
<table class="liste zahlen">
<thead>
<tr>
<th>Datum</th><th>Vorgang</th><th>Depot</th>
<th class="rechts">Stück</th><th class="rechts">Kurs</th>
<th class="rechts">Betrag</th><th class="rechts">Kosten</th>
<th>Verrechnung</th>
</tr>
</thead>
<tbody>
<?php foreach ($buchungen as $buchung): ?>
<tr>
<td><?= View::h(View::datum($buchung['datum'])) ?></td>
<td>
<?= View::h(Depotbuchung::VORGAENGE[$buchung['vorgang']] ?? $buchung['vorgang']) ?>
<?php if ($buchung['vorgang_qif'] !== ''): ?>
<span class="nebentext"><?= View::h($buchung['vorgang_qif']) ?></span>
<?php endif; ?>
</td>
<td><?= View::h($buchung['depot']) ?></td>
<td class="rechts">
<?= (float)$buchung['stueck'] === 0.0
? '–' : View::h(View::stueck($buchung['stueck'])) ?>
</td>
<td class="rechts">
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>
</td>
<td class="rechts"><?= View::betrag($buchung['betrag']) ?></td>
<td class="rechts">
<?php $kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern']; ?>
<?= $kosten > 0.0 ? View::betrag($kosten) : '–' ?>
</td>
<td>
<?php if ($buchung['buchung_id'] !== null): ?>
<a href="/buchungen/<?= (int)$buchung['buchung_id'] ?>">
<?= View::h($buchung['verrechnungskonto'] ?? 'Buchung') ?>
</a>
<?php else: ?>
<span class="nebentext">–</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="hinweis klein">
Im Zeitraum:
Zugänge <?= View::h(View::stueck($zugang['stueck'])) ?> Stück
für <?= View::betrag($zugang['betrag']) ?> ·
Abgänge <?= View::h(View::stueck($abgang['stueck'])) ?> Stück
für <?= View::betrag($abgang['betrag']) ?> ·
Erträge <?= View::betrag($ertrag) ?>.
Zugänge samt Nebenkosten, Abgänge und Erträge abzüglich – so, wie das Geld
geflossen ist.
</p>
<?php endif; ?>
<?php include __DIR__ . '/../layout/fuss.php'; ?>

View file

@ -105,7 +105,8 @@ include __DIR__ . '/../layout/kopf.php';
<?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>> <?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>>
</label> </label>
</td> </td>
<td class="rechts"> <td class="rechts nowrap">
<a href="/wertpapiere/papier/<?= (int)$papier['id'] ?>" class="knopf klein">Details</a>
<button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button> <button type="submit" form="<?= $f ?>" class="knopf klein">Speichern</button>
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
use Finanzmanager\Models\Depotbuchung;
use Finanzmanager\Models\Konto;
use Finanzmanager\Models\Wertpapier;
use Finanzmanager\Services\Depot;
use Finanzmanager\Services\Zeitraum;
use Finanzmanager\View;
/**
* Die Detailseite eines Wertpapiers: Sie rechnet dieselben Zahlen wie die
* Depotübersicht, aber für ein einziges Papier über alle Depots hinweg – und
* zeigt darunter die Buchungen eines wählbaren Zeitraums.
*/
class WertpapierDetailTest extends TestCase
{
protected string $testName = 'Test 26: Wertpapier-Detailseite';
private int $depot = 0;
private int $zweitdepot = 0;
private int $verrechnung = 0;
private int $papier = 0;
private int $anderes = 0;
public function run(): void
{
$this->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<string,mixed> $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();
}
}

154
tests/27_KursabrufTest.php Normal file
View file

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

View file

@ -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')
})
})