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:
commit
b7dc49dee5
21 changed files with 1465 additions and 51 deletions
|
|
@ -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 `<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**
|
||||
|
|
|
|||
69
Changes.md
69
Changes.md
|
|
@ -1,5 +1,74 @@
|
|||
# Änderungen
|
||||
|
||||
## Kurse von Yahoo Finanzen (13.08.2026)
|
||||
|
||||
Dritter Anbieter im Kursabruf: **Yahoo Finanzen**, ohne Schlüssel und ohne Tageskontingent.
|
||||
Eine offene Schnittstelle gibt es dort nicht mehr – `query1.finance.yahoo.com` antwortet
|
||||
ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb –, gelesen wird deshalb die Kursseite
|
||||
selbst.
|
||||
|
||||
- Auswahl *Yahoo Finanzen* im Einstellungsdialog unter *Wertpapiere*; das Feld für den
|
||||
API-Schlüssel bleibt dort leer.
|
||||
- Das **Kürzel muss in den Stammdaten stehen** und den Handelsplatz nennen (`ALV.DE`,
|
||||
`PTX.F`). Yahoo hat keine ISIN-Suche mehr, `/lookup` und `/search` antworten mit 404.
|
||||
- `Kursabruf::yahooKurs()` liest beide Ausprägungen der Seite (`<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)
|
||||
|
||||
Bisher konnte eine Depotbuchung ihre Geldbuchung nur selbst anlegen. Der Weg, den der
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -116,6 +116,30 @@ Einstand.
|
|||
Vollständig verkaufte Positionen verschwinden aus der Bestandsliste; ihr realisierter
|
||||
Gewinn zählt weiter mit.
|
||||
|
||||
## Detailseite eines Wertpapiers
|
||||
|
||||
Der Name in der Bestandsübersicht, in der Liste der Depotbuchungen und in den Stammdaten
|
||||
führt auf `/wertpapiere/papier/{id}` – alles zu einem Papier auf einer Seite:
|
||||
|
||||
* **Kennzahlen** über die ganze Historie: Bestand, Wert, Einstand, nicht realisierter und
|
||||
realisierter Gewinn, Erträge. Wer wissen will, was ein Papier gebracht hat, meint alles
|
||||
seit dem ersten Kauf – nicht den gewählten Berichtszeitraum.
|
||||
* **Stammdaten und Kurs** in je einer Kachel: ISIN, WKN, Art, Währung, Kürzel, Status,
|
||||
erste und letzte Buchung; daneben der letzte Kurs mit Datum und Quelle, Hoch und Tief
|
||||
der letzten zwölf Monate (`Wertpapier::kurseckwerte()`) und der Einstand je Stück.
|
||||
* **Bestand je Depot**: Ein Papier kann in mehreren Depots liegen; gerechnet wird je Depot,
|
||||
weil auch der Einstand je Depot geführt wird. Ist alles verkauft, bleibt die Seite
|
||||
bestehen – realisierte Gewinne und Erträge zählen weiter.
|
||||
* **Wertentwicklung** – dasselbe Diagramm wie in der Depotübersicht, nur auf dieses Papier
|
||||
gerechnet, und nur, wenn es Kurshistorie gibt.
|
||||
* **Buchungen** eines Zeitraums, voreingestellt das laufende Jahr, mit denselben Vorgaben
|
||||
wie die Auswertungen (`Services\Zeitraum`). Darunter eine Zeile mit Zu- und Abgängen und
|
||||
den Erträgen des Zeitraums.
|
||||
|
||||
Gerechnet wird nicht neu: `Depot::bestand()` nimmt eine Wertpapiernummer entgegen und
|
||||
liefert dieselben Zahlen wie die Übersicht, nur auf ein Papier eingegrenzt. Damit können
|
||||
Detailseite und Übersicht nicht auseinanderlaufen.
|
||||
|
||||
## Kurse
|
||||
|
||||
Ein Kurs je Wertpapier und Tag. Mehr Auflösung braucht eine Vermögensübersicht nicht, und
|
||||
|
|
@ -127,15 +151,37 @@ Ersatzwert wäre bequem, verstellte aber den Blick.
|
|||
|
||||
### Kursabruf
|
||||
|
||||
`Services\Kursabruf` kennt zwei Anbieter (siehe `Aktienkurse.md`):
|
||||
`Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`):
|
||||
|
||||
* **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt
|
||||
* **Financial Modeling Prep** – 250 Abrufe/Tag
|
||||
* **Financial Modeling Prep** – 250 Abrufe/Tag, im Gratistarif ohne ISIN-Suche und ohne
|
||||
deutsche Börsenplätze (beides antwortet mit HTTP 402)
|
||||
* **Yahoo Finanzen** – kein Schlüssel, kein Kontingent, dafür muss das Kürzel in den
|
||||
Stammdaten stehen
|
||||
|
||||
Beide arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene Kürzel wird
|
||||
am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das halbiert den
|
||||
Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für heute noch
|
||||
kein Kurs vorliegt.
|
||||
Die ersten beiden arbeiten in zwei Schritten: ISIN → Kürzel, Kürzel → Kurs. Das gefundene
|
||||
Kürzel wird am Wertpapier gespeichert, damit der erste Schritt künftig entfällt – das
|
||||
halbiert den Verbrauch. Abgerufen wird nur für Papiere **mit Bestand**, und nur, wenn für
|
||||
heute noch kein Kurs vorliegt.
|
||||
|
||||
**Yahoo Finanzen** hat keine offene Schnittstelle mehr: `query1.finance.yahoo.com`
|
||||
antwortet ohne Sitzung mit HTTP 429, auch mit Cookie und Crumb, und `/lookup` wie `/search`
|
||||
sind abgeschaltet. Gelesen wird deshalb die Kursseite `de.finance.yahoo.com/quote/<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
|
||||
gehört in einen Cron-Eintrag:
|
||||
|
|
@ -265,6 +311,13 @@ Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen:
|
|||
Geschäft, Kosten in Höhe der Buchung, Konto statt Depot, Kauf ohne Stückzahl. Die beiden
|
||||
Dialoge prüft `tests/e2e/specs/wertpapiergeschaeft.spec.js`.
|
||||
|
||||
`tests/26_WertpapierDetailTest.php` – die Detailseite: der auf ein Papier eingegrenzte
|
||||
Bestand (fremde Papiere bleiben draußen, mehrere Depots werden je Position geführt, Depot
|
||||
und Papier lassen sich zugleich eingrenzen), Hoch und Tief der Kurse, die Datumsgrenzen
|
||||
der Depotbuchungen, das Format der Stückzahlen sowie die gerenderte Seite: Stammdaten,
|
||||
Kennzahlen und eine Buchungsliste, die dem Zeitraum folgt, während die Kennzahlen es nicht
|
||||
tun. Den Klick vom Namen in der Übersicht prüft `tests/e2e/specs/wertpapierdetail.spec.js`.
|
||||
|
||||
`tests/21_DepotbestandImportTest.php` – der CSV-Bestandsimport an einem Ausschnitt des
|
||||
echten DKB-Exports: Vorschau ohne Nebenwirkung, Einstand und Wert aus beiden Kursen,
|
||||
Bruchstückzahlen, Assetklasse → Art, hinterlegte Bewertungskurse, Idempotenz beim zweiten
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
$this->verlangeAnmeldung();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<array<string,mixed>> */
|
||||
public static function kursverlauf(int $wertpapierId, int $tage = 365): array
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<array<string,mixed>>,
|
||||
* 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<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 = [];
|
||||
|
||||
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<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];
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string,mixed> $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<string,mixed> $wertpapier
|
||||
*/
|
||||
private static function yahoo(array $wertpapier): ?float
|
||||
{
|
||||
$symbol = trim((string)$wertpapier['symbol']);
|
||||
|
||||
if ($symbol === '') {
|
||||
throw new \RuntimeException(
|
||||
'Yahoo Finanzen kennt keine ISIN-Suche – bitte das Kürzel in den '
|
||||
. 'Stammdaten hinterlegen (Xetra-Werte enden auf .DE, etwa ALV.DE).'
|
||||
);
|
||||
}
|
||||
|
||||
$html = self::holeSeite('https://de.finance.yahoo.com/quote/' . rawurlencode($symbol) . '/');
|
||||
|
||||
// Yahoo liefert über sein Auslieferungsnetz gelegentlich eine ältere
|
||||
// Fassung der Seite. Der angezeigte Stand („Markt geöffnet“, „Bei
|
||||
// Börsenschluss“) landet deshalb im Protokoll – daran lässt sich ein
|
||||
// alter Kurs erkennen, wenn einer auffällt.
|
||||
if (preg_match('/marketTimeNotice[^>]*>(.*?)<\/div>/s', $html, $stand) === 1) {
|
||||
DebugLog::schreibe(
|
||||
'kursabruf',
|
||||
$symbol . ': ' . trim((string)preg_replace('/\s+/', ' ', strip_tags($stand[1])))
|
||||
);
|
||||
}
|
||||
|
||||
return self::yahooKurs($html, $symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Kurs aus einer Yahoo-Kursseite. Getrennt vom Abruf, damit die
|
||||
* Auswertung ohne Netz geprüft werden kann.
|
||||
*
|
||||
* Yahoo liefert die Seite in zwei Ausprägungen aus – mal mit dem
|
||||
* maschinenlesbaren `<fin-streamer>` (Punkt als Dezimaltrenner), mal nur mit
|
||||
* dem angezeigten Kurs (deutsche Schreibweise). Beides kommt vor, teils am
|
||||
* selben Tag für verschiedene Papiere.
|
||||
*/
|
||||
public static function yahooKurs(string $html, string $symbol): ?float
|
||||
{
|
||||
// Der Titel lautet „Allianz SE (ALV.DE) Aktienkurs …“. Steht dort ein
|
||||
// anderes Kürzel, zeigt die Seite ein anderes Papier – dann lieber kein
|
||||
// Kurs als der falsche.
|
||||
if (preg_match('#<title>(.*?)</title>#is', $html, $titel) === 1
|
||||
&& !str_contains($titel[1], '(' . $symbol . ')')
|
||||
) {
|
||||
throw new \RuntimeException(
|
||||
'Yahoo zeigt unter dem Kürzel ' . $symbol . ' eine andere Seite an.'
|
||||
);
|
||||
}
|
||||
|
||||
$streamer = '/<fin-streamer[^>]*data-symbol="' . preg_quote($symbol, '/') . '"'
|
||||
. '[^>]*data-field="regularMarketPrice"[^>]*data-value="([0-9.]+)"/i';
|
||||
|
||||
if (preg_match($streamer, $html, $treffer) === 1) {
|
||||
return self::zahl($treffer[1]);
|
||||
}
|
||||
|
||||
if (preg_match('/data-testid="qsp-price"[^>]*>([^<]+)</i', $html, $treffer) === 1) {
|
||||
return self::zahl(self::deutscheZahl($treffer[1]));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „1.234,56“ wird zu 1234.56. Ohne Komma bleibt die Zahl, wie sie ist –
|
||||
* Yahoo liefert je nach Ausprägung der Seite die eine oder die andere
|
||||
* Schreibweise.
|
||||
*/
|
||||
private static function deutscheZahl(string $wert): string
|
||||
{
|
||||
$wert = trim(str_replace(["\u{00A0}", ' ', ' '], '', $wert));
|
||||
|
||||
if (str_contains($wert, ',')) {
|
||||
$wert = str_replace(['.', ','], ['', '.'], $wert);
|
||||
}
|
||||
|
||||
return $wert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein GET gegen den Kursdienst, Antwort als Feld.
|
||||
*
|
||||
* @return array<mixed>
|
||||
*/
|
||||
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)) {
|
||||
|
|
|
|||
10
src/View.php
10
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
|
||||
|
|
|
|||
|
|
@ -157,16 +157,19 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endforeach; ?>
|
||||
</select>
|
||||
<p class="feldhinweis">
|
||||
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
|
||||
<code>ALV.DE</code> für Xetra).
|
||||
</p>
|
||||
|
||||
<label for="wertpapiere_api_key">API-Schlüssel</label>
|
||||
<input type="text" id="wertpapiere_api_key" name="wertpapiere_api_key" autocomplete="off"
|
||||
value="<?= View::h($wert('wertpapiere_api_key')) ?>">
|
||||
<p class="feldhinweis">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<label class="ankreuzfeld">
|
||||
|
|
|
|||
|
|
@ -112,9 +112,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= View::h($geschaeft['depot']) ?></td>
|
||||
<td class="rechts">
|
||||
<?= View::h(rtrim(rtrim(number_format($geschaeft['stueck'], 6, ',', '.'), '0'), ',')) ?>
|
||||
</td>
|
||||
<td class="rechts"><?= View::h(View::stueck($geschaeft['stueck'])) ?></td>
|
||||
<td class="rechts"><?= View::betrag($geschaeft['erloes']) ?></td>
|
||||
<td class="rechts"><?= View::betrag($geschaeft['einstand']) ?></td>
|
||||
<td class="rechts <?= View::betragKlasse($geschaeft['gewinn']) ?>">
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= View::h($buchung['wertpapier']) ?>
|
||||
<a href="/wertpapiere/papier/<?= (int)$buchung['wertpapier_id'] ?>">
|
||||
<?= View::h($buchung['wertpapier']) ?>
|
||||
</a>
|
||||
<?php if ($buchung['isin'] !== ''): ?>
|
||||
<span class="nebentext"><?= View::h($buchung['isin']) ?></span>
|
||||
<?php endif; ?>
|
||||
|
|
@ -122,8 +124,7 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<td><?= View::h($buchung['depot']) ?></td>
|
||||
<td class="rechts">
|
||||
<?= (float)$buchung['stueck'] === 0.0
|
||||
? '–'
|
||||
: View::h(rtrim(rtrim(number_format((float)$buchung['stueck'], 6, ',', '.'), '0'), ',')) ?>
|
||||
? '–' : View::h(View::stueck($buchung['stueck'])) ?>
|
||||
</td>
|
||||
<td class="rechts">
|
||||
<?= $buchung['kurs'] === null ? '–' : View::h(number_format((float)$buchung['kurs'], 2, ',', '.')) ?>
|
||||
|
|
|
|||
|
|
@ -115,13 +115,15 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?php foreach ($bestand['positionen'] as $position): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?= View::h($position['name']) ?>
|
||||
<a href="/wertpapiere/papier/<?= (int)$position['wertpapier_id'] ?>">
|
||||
<?= View::h($position['name']) ?>
|
||||
</a>
|
||||
<?php if ($position['isin'] !== ''): ?>
|
||||
<span class="nebentext"><?= View::h($position['isin']) ?></span>
|
||||
<?php endif; ?>
|
||||
</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">
|
||||
<?= $position['einstand_je_stueck'] === null
|
||||
? '–' : View::h(number_format($position['einstand_je_stueck'], 2, ',', '.')) ?>
|
||||
|
|
|
|||
334
src/Views/wertpapiere/papier.php
Normal file
334
src/Views/wertpapiere/papier.php
Normal 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'; ?>
|
||||
|
|
@ -105,7 +105,8 @@ include __DIR__ . '/../layout/kopf.php';
|
|||
<?= (int)$papier['aktiv'] === 1 ? 'checked' : '' ?>>
|
||||
</label>
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
270
tests/26_WertpapierDetailTest.php
Normal file
270
tests/26_WertpapierDetailTest.php
Normal 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
154
tests/27_KursabrufTest.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Finanzmanager\Services\Kursabruf;
|
||||
|
||||
/**
|
||||
* Der Kursabruf hängt an fremden Servern – geprüft wird deshalb nur, was ohne
|
||||
* Netz nachvollziehbar ist: die Adressen der Anbieter und die Aufbereitung
|
||||
* ihrer Fehlermeldungen.
|
||||
*
|
||||
* Der Anlass: Financial Modeling Prep hat die alten `/api/v3/`-Adressen zum
|
||||
* 31. August 2025 abgeschaltet und antwortet dort nur noch mit HTTP 403. Die
|
||||
* Anwendung meldete daraufhin „Der API-Schlüssel wurde abgelehnt“ – falsche
|
||||
* Fährte, der Schlüssel war in Ordnung.
|
||||
*/
|
||||
class KursabrufTest extends TestCase
|
||||
{
|
||||
protected string $testName = 'Test 27: Kursabruf';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->adressen();
|
||||
$this->meldungen();
|
||||
$this->yahooAusMarkup();
|
||||
$this->yahooOhneKuerzel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Yahoo liefert die Kursseite in zwei Ausprägungen aus – beide müssen
|
||||
* denselben Kurs ergeben. Das Markup stammt aus den echten Seiten vom
|
||||
* 13.08.2026, gekürzt auf das Wesentliche.
|
||||
*/
|
||||
private function yahooAusMarkup(): void
|
||||
{
|
||||
$streamer = '<title>Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen</title>'
|
||||
. '<fin-streamer key="price" class="last-price" data-symbol="ALV.DE"'
|
||||
. ' data-field="regularMarketPrice" data-trend="none" data-pricehint="2"'
|
||||
. ' data-value="441.7" active></fin-streamer>';
|
||||
|
||||
$this->assertEquals(441.7, Kursabruf::yahooKurs($streamer, 'ALV.DE'), 'Kurs aus <fin-streamer>');
|
||||
|
||||
$angezeigt = '<title>SAP SE (SAP.DE) Aktienkurs – Yahoo Finanzen</title>'
|
||||
. '<span class="price" data-testid="qsp-price">177,22 </span>'
|
||||
. '<span data-testid="qsp-price-change">+1,26 </span>';
|
||||
|
||||
$this->assertEquals(177.22, Kursabruf::yahooKurs($angezeigt, 'SAP.DE'), 'Kurs aus dem angezeigten Preis');
|
||||
|
||||
$tausender = '<title>Test (XYZ.DE) – Yahoo Finanzen</title>'
|
||||
. '<span data-testid="qsp-price">1.234,56 </span>';
|
||||
|
||||
$this->assertEquals(1234.56, Kursabruf::yahooKurs($tausender, 'XYZ.DE'), 'Tausenderpunkt und Dezimalkomma');
|
||||
|
||||
// Die Indexleiste am Seitenkopf führt fremde Kurse mit – die dürfen
|
||||
// nicht als Kurs des Papiers durchgehen.
|
||||
$fremd = '<title>Allianz SE (ALV.DE) Aktienkurs – Yahoo Finanzen</title>'
|
||||
. '<fin-streamer data-symbol="^GDAXI" data-field="regularMarketPrice" data-value="26435.66"></fin-streamer>'
|
||||
. '<fin-streamer data-symbol="ALV.DE" data-field="regularMarketPrice" data-value="441.7"></fin-streamer>';
|
||||
|
||||
$this->assertEquals(441.7, Kursabruf::yahooKurs($fremd, 'ALV.DE'), 'Der DAX-Stand der Kopfleiste zählt nicht');
|
||||
|
||||
$this->assertNull(
|
||||
Kursabruf::yahooKurs('<title>Allianz SE (ALV.DE)</title><p>Umbau</p>', 'ALV.DE'),
|
||||
'Ohne bekanntes Muster kommt kein Kurs statt eines geratenen'
|
||||
);
|
||||
|
||||
// Yahoo zeigt für Papiere ohne Umsatz „--“ an – das ist kein Kurs.
|
||||
$this->assertNull(
|
||||
Kursabruf::yahooKurs(
|
||||
'<title>Commerce Bancshares Inc (CB5.DE)</title><span data-testid="qsp-price">--</span>',
|
||||
'CB5.DE'
|
||||
),
|
||||
'Ein Strich statt einer Zahl gilt nicht als Kurs'
|
||||
);
|
||||
|
||||
// Ein falsches Kürzel darf keinen fremden Kurs in den Bestand tragen.
|
||||
$fehler = '';
|
||||
try {
|
||||
Kursabruf::yahooKurs('<title>Siemens AG (SIE.DE) Aktienkurs</title>'
|
||||
. '<span data-testid="qsp-price">210,00</span>', 'ALV.DE');
|
||||
} catch (\RuntimeException $e) {
|
||||
$fehler = $e->getMessage();
|
||||
}
|
||||
$this->assert(str_contains($fehler, 'ALV.DE'), 'Eine fremde Seite wird am Titel erkannt');
|
||||
}
|
||||
|
||||
/** Ohne Kürzel kann Yahoo nichts finden – das muss die Meldung sagen. */
|
||||
private function yahooOhneKuerzel(): void
|
||||
{
|
||||
$kurs = new ReflectionMethod(Kursabruf::class, 'yahoo');
|
||||
$kurs->setAccessible(true);
|
||||
|
||||
$fehler = '';
|
||||
try {
|
||||
$kurs->invoke(null, ['id' => 0, 'symbol' => ' ', 'isin' => 'DE0008404005', 'name' => 'Allianz']);
|
||||
} catch (\RuntimeException $e) {
|
||||
$fehler = $e->getMessage();
|
||||
}
|
||||
|
||||
$this->assert(str_contains($fehler, 'Kürzel'), 'Das fehlende Kürzel wird benannt');
|
||||
$this->assert(str_contains($fehler, 'ALV.DE'), 'Die Meldung nennt ein Beispiel');
|
||||
}
|
||||
|
||||
/** Keine abgeschalteten Adressen mehr im Quelltext. */
|
||||
private function adressen(): void
|
||||
{
|
||||
$quelle = (string)file_get_contents(dirname(__DIR__) . '/src/Services/Kursabruf.php');
|
||||
|
||||
$this->assert(
|
||||
!str_contains($quelle, 'financialmodelingprep.com/api/v3'),
|
||||
'Financial Modeling Prep wird nicht mehr über /api/v3 angesprochen'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($quelle, 'financialmodelingprep.com/stable/quote?symbol='),
|
||||
'Der Kurs kommt über /stable/quote'
|
||||
);
|
||||
$this->assert(
|
||||
str_contains($quelle, 'financialmodelingprep.com/stable/search-isin?isin='),
|
||||
'Die ISIN wird über /stable/search-isin aufgelöst'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Begründung des Dienstes gehört in die Fehlermeldung – sonst sucht man
|
||||
* beim Schlüssel, wenn die Adresse das Problem ist.
|
||||
*/
|
||||
private function meldungen(): void
|
||||
{
|
||||
$meldung = new ReflectionMethod(Kursabruf::class, 'meldung');
|
||||
$meldung->setAccessible(true);
|
||||
|
||||
$json = (string)$meldung->invoke(null, '{"Error Message": "Legacy Endpoint : nicht mehr\nunterstützt"}');
|
||||
$this->assertEquals(
|
||||
' Legacy Endpoint : nicht mehr unterstützt',
|
||||
$json,
|
||||
'Aus JSON wird der Klartext einzeilig übernommen'
|
||||
);
|
||||
|
||||
$text = (string)$meldung->invoke(null, 'Restricted Endpoint: nicht im Tarif');
|
||||
$this->assertEquals(
|
||||
' Restricted Endpoint: nicht im Tarif',
|
||||
$text,
|
||||
'Antworten ohne JSON werden unverändert übernommen'
|
||||
);
|
||||
|
||||
$this->assertEquals('', (string)$meldung->invoke(null, ' '), 'Leere Antworten hängen nichts an');
|
||||
$this->assertEquals('', (string)$meldung->invoke(null, '[]'), 'Ein leeres Feld hängt nichts an');
|
||||
|
||||
$lang = (string)$meldung->invoke(null, str_repeat('x', 500));
|
||||
// Ein Leerzeichen davor, 200 Zeichen Text, ein Auslassungszeichen dahinter.
|
||||
$this->assertEquals(202, mb_strlen($lang), 'Lange Antworten werden auf 200 Zeichen gekürzt');
|
||||
$this->assert(str_ends_with($lang, '…'), 'Gekürzte Antworten enden mit Auslassungszeichen');
|
||||
}
|
||||
}
|
||||
146
tests/e2e/specs/wertpapierdetail.spec.js
Normal file
146
tests/e2e/specs/wertpapierdetail.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue