Finanzmanager/docs/wertpapiere.md
fds2610-bot 9d771df028 Fix #129: Stück, Kurs und Betrag in der Depotbuchung automatisch berechnen
In „Depotbuchung erfassen“ ergibt sich bei Kauf und Verkauf aus zwei der drei
Felder Betrag, Stück und Kurs das dritte (Betrag = Stück × Kurs). Der
ausgerechnete Wert ist hervorgehoben und zieht nach, wenn sich die anderen
Felder ändern. Serverseitig rechnet Depotbuchung::dritterWert() dieselbe
Regel für den Weg ohne JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:41:29 +02:00

340 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Wertpapiere und Depots
Ein Depot ist ein Konto der Kontoart **Depot**. Was darin liegt, steht nicht in
`buchungen`, sondern in `depot_buchungen` – dort gehören Stückzahl und Kurs dazu, und der
Saldo ist keine Summe von Beträgen, sondern der bewertete Bestand.
## Zwei Seiten je Geschäft
Jedes Wertpapiergeschäft erzeugt bis zu zwei Datensätze:
* die **Depotbuchung** mit Stück, Kurs, Gebühren und Steuern,
* die **Geldbuchung** auf dem Verrechnungskonto – eine gewöhnliche Buchung, die im
Kontoblatt steht wie jede andere.
Verbunden sind sie über `buchung_id`. So bleibt das Girokonto stimmig, ohne dass
Kontoblätter Stückzahlen führen müssten.
Beide Richtungen kommen vor, und `buchung_erzeugt` hält fest, welche es war:
* **Depotbuchung zuerst** (Maske unter *Wertpapiere*, QIF-Import): Sie legt die
Geldbuchung an. Wird sie gelöscht, geht die Geldbuchung mit – sie hat ohne das Geschäft
keinen Sinn.
* **Geldbuchung zuerst** (Bankabruf, siehe *Geschäft aus einer Kontobuchung*): Die
Depotbuchung hängt sich an die vorhandene Buchung. Wird sie gelöscht, bleibt die Buchung
der Bank stehen und ist nur wieder unzugeordnet – sie ist deren Beleg, nicht unsere.
Ein- und Auslieferungen rühren kein Konto an und haben deshalb keine Geldbuchung.
## Geschäft aus einer Kontobuchung
Der Weg, den der Alltag nimmt: Der Bankabruf liefert die Wertpapierabrechnung als Umsatz
auf dem Girokonto – ein Betrag, ein Verwendungszweck, sonst nichts. Im Kontoblatt öffnet
die Zuordnungsliste unter *Wertpapiergeschäft im Depot* die Wertpapiermaske; die schlichte
Umbuchung auf dasselbe Depot steht daneben weiter zur Wahl.
Zwei Dialoge, wie im FinanzManager: vorne Vorgangsart, Datum, Wertpapier, Stück und Kurs,
dahinter hinter *Kosten …* die Nebenkosten nach Arten. ISIN und WKN liest
`Wertpapier::ausText()` aus dem Verwendungszweck („WKN LYX01W … ISIN LU1834983477“) und
wählt das Papier vor. Ohne JavaScript führt derselbe Weg auf
`/buchungen/{id}/wertpapier` – dieselben Felder, untereinander statt hinter einem Knopf.
**Der Buchungsbetrag ist die feste Größe.** Er kommt von der Bank und wird nicht
nachgerechnet. Die Nebenkosten gehen aus ihm heraus, der Rest ist der Kurswert und damit
der Einstandspreis:
```
Kurswert = Buchungsbetrag + Summe der Nebenkosten
Kauf −255,00 € + 5,00 € = −250,00 € → Einstand 250,00 €
Verkauf 3.103,17 € + 84,83 € = 3.188,00 € → Erlös 3.188,00 €
```
Dieselbe Rechnung in beiden Richtungen: Nebenkosten sind Aufwand, ob gekauft oder verkauft
wird. Der Kurs ist demgegenüber eine Angabe der Abrechnung; fehlt er, ergibt er sich aus
Einstand und Stückzahl – nie umgekehrt.
Im Kontoblatt entsteht daraus eine **Splitbuchung**: je belegter Kostenart eine Zeile in
ihrer Kategorie, der Rest als Umbuchung ins Depot. Die Zeilen ergeben genau den
Buchungsbetrag. Ohne Nebenkosten bleibt es bei der schlichten Umbuchung – eine
Splitbuchung mit einer einzigen Zeile wäre nur umständlicher zu lesen.
**Klasse.** Die Maske bietet eine Klasse an, vorbelegt mit der bisherigen Klasse der
Buchung. Sie gilt für das ganze Geschäft: an der Buchung und an jeder Splitzeile, damit
das Kontoblatt keine gemischten Klassen zeigt und die Nebenkosten in der Auswertung nach
Klassen nicht fehlen. Eine leere Auswahl entfernt die Klasse. Auch die Maske *Depotbuchung
erfassen* unter *Wertpapiere* hat das Feld; dort steht die Klasse an der Geldbuchung auf
dem Verrechnungskonto.
### Kostenarten und ihre Kategorien
`Services\Wertpapierkosten` kennt sechs Gebühren- und sechs Steuerarten – dieselben, die
der FinanzManager im Kostendialog führt:
| Gebühren | Steuern |
|---|---|
| Bankprovision | Zinsabschlagsteuer |
| Maklercourtage | Kapitalertragsteuer |
| Börsenplatzentgelt | Solidaritätszuschlag |
| Spesen | Ausländische Quellensteuer |
| Spesen Ausland | Abgeltungsteuer |
| Sonstige Kosten | Kirchensteuer |
Welche Kategorie zu welcher Art gehört, steht **einmal** unter *Einstellungen →
Wertpapiere* (`wertpapiere_kategorie_<art>`); der Dialog fragt dann nur noch Beträge ab.
Einzige Ausnahme ist *Sonstige Kosten*: Deren Kategorie lässt sich an Ort und Stelle
wählen, weil sie nicht immer dieselbe ist. Arten ohne hinterlegte Kategorie werden zu
Splitzeilen ohne Zuordnung, nicht zu einem Fehler.
In der Depotbuchung stehen die Kosten nur als zwei Summen (`gebuehren`, `steuern`) – für
den Einstand genügt das. Die Aufschlüsselung lebt in den Splitzeilen, wo sie gebraucht
wird.
## Was in welchem Feld steht
`betrag` ist der **Bruttowert**: Stück × Kurs, bei Dividenden der Bruttoertrag. Gebühren
und Steuern stehen *daneben*, nicht darin. Daraus folgt der Geldfluss:
| Vorgang | Verrechnungskonto |
|---|---|
| Kauf | −(Betrag + Gebühren + Steuern) |
| Verkauf | +(Betrag − Gebühren − Steuern) |
| Dividende | +(Betrag − Steuern) |
Am echten Export nachgerechnet: Ein Verkauf über 4 × 797,00 € mit 15,37 € Gebühren und
69,46 € Steuern ergibt 3.103,17 € Gutschrift – genau der Wert, den das QIF im Feld `$`
nennt. Diese Trennung ist nötig, damit der Einstand die Nebenkosten **genau einmal**
enthält.
`stueck` trägt die Richtung: Zugänge positiv, Abgänge negativ, Dividenden null.
In der Maske *Depotbuchung erfassen* genügen bei Kauf und Verkauf zwei der drei Angaben
Stück, Kurs und Betrag: Sobald zwei eingetragen sind, rechnet `public/static/depotbuchung.js`
das dritte aus (Betrag auf zwei, Stück und Kurs auf sechs Nachkommastellen) und hebt es
farblich hervor. Ein ausgerechneter Wert wird nachgezogen, wenn sich eines der anderen
Felder ändert; tippt man ihn selbst um, gilt er als eingetragen. Ohne JavaScript rechnet
`Depotbuchung::dritterWert()` dieselbe Regel beim Speichern.
## Einstand nach gleitendem Durchschnitt
Bei jedem Kauf steigt der Einstand um Kaufpreis samt Gebühren, bei jedem Verkauf sinkt er
anteilig um den bis dahin durchschnittlichen Stückpreis. So rechnet auch der
FinanzManager, und die Frage, *welches* Stück verkauft wurde, stellt sich gar nicht.
Der **realisierte Gewinn** eines Verkaufs ist Erlös minus Kosten minus anteiligem
Einstand.
> Für die Steuererklärung gilt in Deutschland FIFO. Maßgeblich ist dort die Abrechnung der
> Bank – diese Übersicht ersetzt sie nicht.
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
die Freikontingente der Kursdienste geben nicht mehr her.
**Ohne Kurs wird nicht bewertet**: Die Position zeigt „kein Kurs“ und fällt aus der
Wertsumme; die Übersicht sagt ausdrücklich, dass sie unvollständig ist. Der Einstand als
Ersatzwert wäre bequem, verstellte aber den Blick.
### Kursabruf
`Services\Kursabruf` kennt drei Anbieter (siehe `Aktienkurse.md`):
* **Leeway.tech** – 50 Abrufe/Tag, kennt ISINs direkt
* **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
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:
```bash
docker compose exec -u www-data php php scripts/kursabruf.php
```
Das Skript bricht ab, solange der automatische Abruf in den Einstellungen nicht
eingeschaltet ist – ein vergessener Cron-Eintrag kostet so kein Kontingent. Der Knopf auf
der Wertpapierseite arbeitet unabhängig davon.
Die Feldnamen der Antworten werden nachsichtig gelesen (`close`, `price`, `last` …): Die
Dienste benennen sie unterschiedlich und gelegentlich um. Der API-Schlüssel steht in der
Adresse und wird vor jedem Logeintrag maskiert.
## QIF-Import (`!Type:Invst`)
Der Import legt Wertpapier, Depotbuchung und Geldbuchung in einem Zug an. Zwei
Eigenheiten des Formats:
* **Das `L`-Feld trägt bei Wertpapiergeschäften mehr als sonst.** Dem Verrechnungskonto
kann eine Kategorie vorangehen: `LKursgewinne:Realisierte Gewinne|[Verrechnungskonto]`.
Gelesen wird deshalb nur der Teil hinter dem letzten senkrechten Strich.
* **Das Feld `O` führt zwölf Beträge.** Sicher zugeordnet sind Position 4 (Provision) und
10 (fremde Spesen) – beides Gebühren. Alles Übrige sind Abzüge des Fiskus und werden zu
„Steuern“ zusammengefasst. Damit fällt keine Position heraus, ohne dass jede einzeln
gedeutet werden müsste.
Fehlt der Kurs – bei Dividenden immer –, wird der Bruttobetrag aus der Gutschrift plus den
Abzügen zurückgerechnet.
`DivIn` und `DivAus` landen beide auf „dividende“; die ursprüngliche Bezeichnung bleibt in
`vorgang_qif` erhalten, weil die Zuordnung nicht in jedem Fall umkehrbar ist.
**Dabei behoben:** Bis Phase 5 buchte der Import den Geldfluss *jedes*
Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden. Das QIF nennt im Feld
`$` immer einen positiven Betrag; die Richtung steckt allein im Vorgang. Verrechnungskonten
von Depots wiesen dadurch zu niedrige Salden aus.
## CSV-Import einer Bestandsliste
*Wertpapiere → Bestände → Import* übernimmt die Depotbestandsliste, wie Banken sie zum
Herunterladen anbieten (erprobt mit der DKB). Erwartet wird eine Kopfzeile mit den Spalten
*Wertpapierbezeichnung*, *WKN*, *ISIN*, *Einstiegskurs*, *Bewertungskurs*, *Stückzahl* und
*Assetklasse*; *Datum der Erstellung* und *Depotnummer* werden mitgelesen, wenn vorhanden.
Trennzeichen (`;`, `,`, Tabulator) und Zeichensatz (UTF-8 mit oder ohne BOM,
Windows-1252) erkennt der Import selbst.
**Der Export kennt keine Historie.** Er beschreibt einen Stichtag: was im Depot liegt und
was es im Schnitt gekostet hat. Die Geschäfte, die dorthin geführt haben, fehlen. Der
Import legt deshalb je Position **eine** Depotbuchung an – einen Kauf zum Stichtag über
die volle Stückzahl zum Einstiegskurs. Der Einstand stimmt damit auf den Cent, frühere
Käufe, Verkäufe und Dividenden bleiben naturgemäß leer und lassen sich später von Hand
nachtragen.
**Zwei Kurse, zwei Rollen.** Der *Einstiegskurs* ist der durchschnittliche Kaufpreis und
wird zum Kurs der Buchung; der *Bewertungskurs* ist der aktuelle Kurs und landet als Kurs
des Stichtags in `kurse`. Erst dadurch ist der frisch übernommene Bestand sofort bewertbar
und zeigt denselben Gewinn wie die Bank – bis auf wenige Cent, die in den auf zwei Stellen
gerundeten Kursen des Exports stecken.
**Kein Geldfluss.** Ein Verrechnungskonto ist wählbar, bleibt aber in aller Regel leer:
Das Geld ist vor Jahren geflossen, eine Belastung zum heutigen Tag würde das Girokonto
verfälschen. Ohne Verrechnungskonto entsteht keine Geldbuchung.
**Assetklasse → Art**: *Aktien* → Aktie, *ETFs* und *Fonds* → Fonds, *Anleihen* →
Anleihe, *Zertifikate* → Zertifikat, alles Übrige → Sonstiges.
Das Zieldepot lässt sich wählen; ohne Auswahl sucht der Import das Depot, dessen
**Kontonummer** der Depotnummer aus der Datei entspricht. Jede Position bekommt einen
`import_hash` aus Depot, Datum, Vorgang, Stück, Betrag und ISIN – dieselbe Datei zweimal
eingelesen legt nichts doppelt an. Ein *anderer* Stichtag gilt dagegen als neuer Vorgang.
Der Ablauf ist zweistufig: Der Upload zeigt erst eine Vorschau mit allen Positionen,
Einstand, Wert und dem Status je Zeile; erst *Jetzt übernehmen* schreibt. Die Datei bleibt
im Importverzeichnis liegen.
## Depotbuchungen löschen
Die Liste der Depotbuchungen löscht einzeln – *Löschen* je Zeile – oder mehrere auf
einmal, genau wie das Kontoblatt: Ankreuzfeld je Zeile, das Feld in der Titelzeile wählt
alle angezeigten, *Auswahl löschen* in der Aktionsleiste schickt sie an
`POST /wertpapiere/buchungen/loeschen`. Der Knopf nennt die Anzahl und bleibt ohne
Auswahl gesperrt; die Rückfrage kommt vor dem Absenden. Ohne JavaScript funktioniert
alles weiter – dann ist der Knopf offen und der Controller weist eine leere Auswahl ab.
Der eingestellte Filter hängt am Ziel beider Formulare, deshalb steht danach wieder
dieselbe Ansicht da. Zusammengesetzt wird er im Controller neu, damit kein fremdes Ziel
eingeschleust werden kann.
Mit jeder Depotbuchung geht ihre **selbst erzeugte** Geldbuchung (siehe *Zwei Seiten je
Geschäft*). Hing sie dagegen an einer Buchung des Bankabrufs, bleibt diese stehen und
verliert nur die Aufteilung des Geschäfts – sie steht danach wieder unzugeordnet im
Kontoblatt. Am
Bestand ist nichts nachzuziehen: Er wird bei jedem Aufruf aus den Depotbuchungen
gerechnet und gibt die Löschung von selbst wieder her – ebenso realisierter Gewinn,
Veräußerungen und Erträge. Das Wertpapier selbst bleibt in den Stammdaten stehen.
## Kontenübersicht
Depots erscheinen dort mit ihrem **bewerteten Bestand**, nicht mit einem Buchungssaldo. Der
Anfangssaldo bleibt dabei: Führt ein Depotkonto eine Barreserve, steht sie dort.
Damit ist die Voraussetzung erfüllt, um die Summe der Kontostände mit dem FinanzManager zu
vergleichen (`PLAN.md`, Kapitel 7) – sobald Kurse für alle Positionen vorliegen.
## Tests
`tests/17_WertpapiereTest.php` – Wiederfinden über ISIN, WKN und Name samt Nachtragen
fehlender Felder; Bestand und Einstand über mehrere Käufe; Geldseite mit richtigem
Vorzeichen; Verkauf mit anteiligem Einstand und realisiertem Gewinn; Dividende ohne
Bestandsänderung; fehlende Kurse; Veräußerungsrechnung über die Historie hinweg; der
QIF-Import eines echten Ausschnitts (Kauf, Dividende, Verkauf) inklusive Idempotenz;
Löschen samt Geldbuchung.
`tests/22_DepotbuchungenLoeschenTest.php` – das Sammellöschen: die Geldbuchungen gehen
mit, das Verrechnungskonto stimmt danach wieder, Bestand, Einstand, realisierter Gewinn,
Erträge und die Veräußerungsauswertung geben die Löschung her, unbekannte Nummern in der
Auswahl bleiben folgenlos. Den Ablauf im Browser prüft `tests/e2e/specs/module.spec.js`.
`tests/25_WertpapiergeschaeftTest.php` – das Geschäft aus einer Kontobuchung: das Depot als
eigener Wert der Zuordnungsliste, ISIN und WKN aus dem Verwendungszweck, Kauf mit Kosten
(Einstand, Splitzeilen, Summe gleich dem Buchungsbetrag, keine zweite Buchung daneben),
Kauf ohne Kosten als schlichte Umbuchung mit errechnetem Kurs, Verkauf mit Gebühren und
Steuern, das Löschen, das die Bankbuchung stehen lässt, sowie die Abweisungen: doppeltes
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`, die Klasse des Geschäfts
`tests/62_WertpapierKlasseTest.php`.
`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
Lauf, Zuordnung des Depots über die Depotnummer, Geldseite mit und ohne
Verrechnungskonto, sowie die Abwehr einer fremden oder leeren Datei.