Der Name in Bestandsübersicht, Depotbuchungen und Stammdaten führt jetzt auf
/wertpapiere/papier/{id}: Kennzahlen über die ganze Historie, Stammdaten und
Kurs, Bestand je Depot, Wertentwicklung und die Buchungen eines Zeitraums.
Depot::bestand() und Depot::entwicklung() nehmen dafür eine Wertpapiernummer
entgegen – die Detailseite rechnet nicht selbst, sondern bekommt dieselben
Zahlen wie die Übersicht. Neu sind Wertpapier::kurseckwerte() und
Depotbuchung::datumsgrenzen().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
342 lines
13 KiB
PHP
342 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Database;
|
||
use Finanzmanager\Mandant;
|
||
use Finanzmanager\Models\Depotbuchung;
|
||
use Finanzmanager\Models\Konto;
|
||
use Finanzmanager\Models\Wertpapier;
|
||
|
||
/**
|
||
* Die Rechenmaschine hinter der Depotübersicht: aus Depotbuchungen und Kursen
|
||
* werden Bestände, Einstandswerte und die Wertentwicklung.
|
||
*
|
||
* **Einstandswert 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. Das ist die Rechnung, die auch
|
||
* der FinanzManager zeigt; sie kommt ohne die Frage aus, welches Stück
|
||
* eigentlich verkauft wurde. (Für die Steuer gilt in Deutschland FIFO – dafür
|
||
* ist die Bank zuständig, nicht diese Übersicht.)
|
||
*
|
||
* **Realisierte Gewinne** entstehen beim Verkauf: Erlös abzüglich des
|
||
* anteiligen Einstands.
|
||
*/
|
||
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,
|
||
* wert: float,
|
||
* gewinn: float,
|
||
* realisiert: float,
|
||
* ertraege: float,
|
||
* bewertbar: bool
|
||
* }
|
||
*/
|
||
public static function bestand(
|
||
?int $kontoId = null,
|
||
?string $stichtag = null,
|
||
?int $wertpapierId = null
|
||
): array {
|
||
$stichtag ??= date('Y-m-d');
|
||
$kurse = Wertpapier::letzteKurse($stichtag);
|
||
|
||
$positionen = [];
|
||
$realisiert = 0.0;
|
||
$ertraege = 0.0;
|
||
|
||
foreach (self::buchungen($kontoId, $stichtag, $wertpapierId) as $buchung) {
|
||
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
|
||
|
||
$position = $positionen[$schluessel] ?? [
|
||
'wertpapier_id' => (int)$buchung['wertpapier_id'],
|
||
'konto_id' => (int)$buchung['konto_id'],
|
||
'name' => (string)$buchung['wertpapier'],
|
||
'isin' => (string)$buchung['isin'],
|
||
'wkn' => (string)$buchung['wkn'],
|
||
'depot' => (string)$buchung['depot'],
|
||
'stueck' => 0.0,
|
||
'einstand' => 0.0,
|
||
'realisiert' => 0.0,
|
||
'ertraege' => 0.0,
|
||
];
|
||
|
||
$stueck = (float)$buchung['stueck'];
|
||
$betrag = (float)$buchung['betrag'];
|
||
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
|
||
|
||
switch ((string)$buchung['vorgang']) {
|
||
case 'kauf':
|
||
case 'einlage':
|
||
// Nebenkosten gehören zum Einstand: Sie sind Teil dessen,
|
||
// was das Papier gekostet hat.
|
||
$position['stueck'] += $stueck;
|
||
$position['einstand'] += $betrag + $kosten;
|
||
break;
|
||
|
||
case 'verkauf':
|
||
case 'entnahme':
|
||
$abgang = abs($stueck);
|
||
$anteil = $position['stueck'] > 0.0
|
||
? min(1.0, $abgang / $position['stueck'])
|
||
: 1.0;
|
||
|
||
$anteiligerEinstand = round($position['einstand'] * $anteil, 2);
|
||
|
||
$position['stueck'] -= $abgang;
|
||
$position['einstand'] -= $anteiligerEinstand;
|
||
$position['realisiert'] += round($betrag - $kosten - $anteiligerEinstand, 2);
|
||
break;
|
||
|
||
case 'dividende':
|
||
$position['ertraege'] += $betrag - $kosten;
|
||
break;
|
||
}
|
||
|
||
$positionen[$schluessel] = $position;
|
||
}
|
||
|
||
$einstandGesamt = 0.0;
|
||
$wertGesamt = 0.0;
|
||
$bewertbar = true;
|
||
$fertig = [];
|
||
|
||
foreach ($positionen as $position) {
|
||
$realisiert += $position['realisiert'];
|
||
$ertraege += $position['ertraege'];
|
||
|
||
// Vollständig verkaufte Positionen verschwinden aus der Übersicht;
|
||
// ihr realisierter Gewinn zählt weiter mit.
|
||
if (round($position['stueck'], 6) <= 0.0) {
|
||
continue;
|
||
}
|
||
|
||
$kurs = $kurse[$position['wertpapier_id']] ?? null;
|
||
|
||
$position['stueck'] = round($position['stueck'], 6);
|
||
$position['einstand'] = round($position['einstand'], 2);
|
||
$position['kurs'] = $kurs['kurs'] ?? null;
|
||
$position['kurs_datum'] = $kurs['datum'] ?? null;
|
||
$position['einstand_je_stueck'] = $position['stueck'] > 0.0
|
||
? round($position['einstand'] / $position['stueck'], 4)
|
||
: null;
|
||
|
||
if ($kurs === null) {
|
||
// Ohne Kurs ist der Wert unbekannt. Der Einstand als Ersatz wäre
|
||
// bequem, verstellte aber den Blick: Die Übersicht sagt lieber,
|
||
// dass ein Kurs fehlt.
|
||
$position['wert'] = null;
|
||
$position['gewinn'] = null;
|
||
$bewertbar = false;
|
||
} else {
|
||
$position['wert'] = round($position['stueck'] * $kurs['kurs'], 2);
|
||
$position['gewinn'] = round($position['wert'] - $position['einstand'], 2);
|
||
$wertGesamt += $position['wert'];
|
||
}
|
||
|
||
$einstandGesamt += $position['einstand'];
|
||
$fertig[] = $position;
|
||
}
|
||
|
||
usort($fertig, static fn(array $a, array $b): int => strcasecmp($a['name'], $b['name']));
|
||
|
||
return [
|
||
'positionen' => $fertig,
|
||
'einstand' => round($einstandGesamt, 2),
|
||
'wert' => round($wertGesamt, 2),
|
||
'gewinn' => round($wertGesamt - $einstandGesamt, 2),
|
||
'realisiert' => round($realisiert, 2),
|
||
'ertraege' => round($ertraege, 2),
|
||
'bewertbar' => $bewertbar,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Der bewertete Depotwert je Konto – die Kontenübersicht braucht ihn für
|
||
* die Depotzeilen, wo ein Saldo aus Buchungen keinen Sinn ergäbe.
|
||
*
|
||
* @return array<int,float> konto_id => Wert
|
||
*/
|
||
public static function werteJeDepot(?string $stichtag = null): array
|
||
{
|
||
$werte = [];
|
||
foreach (self::depots() as $depot) {
|
||
$bestand = self::bestand((int)$depot['id'], $stichtag);
|
||
$werte[(int)$depot['id']] = $bestand['wert'];
|
||
}
|
||
|
||
return $werte;
|
||
}
|
||
|
||
/** Alle Konten der Kontoart „depot“. @return list<array<string,mixed>> */
|
||
public static function depots(bool $nurAktive = false): array
|
||
{
|
||
return array_values(array_filter(
|
||
Konto::alle($nurAktive),
|
||
static fn(array $konto): bool => (string)$konto['kontoart'] === 'depot'
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Veräußerungsgeschäfte eines Zeitraums: je Verkauf der Erlös, der
|
||
* anteilige Einstand und der Gewinn.
|
||
*
|
||
* Gerechnet wird über die ganze Historie bis zum Verkaufstag – der Einstand
|
||
* eines im Mai verkauften Papiers hängt an allen Käufen davor, nicht nur an
|
||
* denen im Berichtszeitraum.
|
||
*
|
||
* @return array{geschaefte: list<array<string,mixed>>, gewinn: float, erloes: float}
|
||
*/
|
||
public static function veraeusserungen(string $von, string $bis, ?int $kontoId = null): array
|
||
{
|
||
$bestaende = [];
|
||
$geschaefte = [];
|
||
$gewinn = 0.0;
|
||
$erloes = 0.0;
|
||
|
||
foreach (self::buchungen($kontoId, $bis) as $buchung) {
|
||
$schluessel = (int)$buchung['wertpapier_id'] . ':' . (int)$buchung['konto_id'];
|
||
$stand = $bestaende[$schluessel] ?? ['stueck' => 0.0, 'einstand' => 0.0];
|
||
|
||
$betrag = (float)$buchung['betrag'];
|
||
$kosten = (float)$buchung['gebuehren'] + (float)$buchung['steuern'];
|
||
$stueck = (float)$buchung['stueck'];
|
||
|
||
if (in_array((string)$buchung['vorgang'], Depotbuchung::ZUGAENGE, true)) {
|
||
$stand['stueck'] += $stueck;
|
||
$stand['einstand'] += $betrag + $kosten;
|
||
} elseif (in_array((string)$buchung['vorgang'], Depotbuchung::ABGAENGE, true)) {
|
||
$abgang = abs($stueck);
|
||
$anteil = $stand['stueck'] > 0.0 ? min(1.0, $abgang / $stand['stueck']) : 1.0;
|
||
$anteiligerEinstand = round($stand['einstand'] * $anteil, 2);
|
||
|
||
$stand['stueck'] -= $abgang;
|
||
$stand['einstand'] -= $anteiligerEinstand;
|
||
|
||
$datum = (string)$buchung['datum'];
|
||
if ($datum >= $von && $datum <= $bis && (string)$buchung['vorgang'] === 'verkauf') {
|
||
$einzel = round($betrag - $kosten - $anteiligerEinstand, 2);
|
||
|
||
$geschaefte[] = [
|
||
'datum' => $datum,
|
||
'name' => (string)$buchung['wertpapier'],
|
||
'isin' => (string)$buchung['isin'],
|
||
'depot' => (string)$buchung['depot'],
|
||
'stueck' => $abgang,
|
||
'erloes' => round($betrag - $kosten, 2),
|
||
'einstand' => $anteiligerEinstand,
|
||
'gewinn' => $einzel,
|
||
];
|
||
|
||
$gewinn += $einzel;
|
||
$erloes += round($betrag - $kosten, 2);
|
||
}
|
||
}
|
||
|
||
$bestaende[$schluessel] = $stand;
|
||
}
|
||
|
||
return [
|
||
'geschaefte' => $geschaefte,
|
||
'gewinn' => round($gewinn, 2),
|
||
'erloes' => round($erloes, 2),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Erträge (Dividenden und Ausschüttungen) eines Zeitraums, je Wertpapier.
|
||
*
|
||
* @return array{zeilen: list<array<string,mixed>>, summe: float}
|
||
*/
|
||
public static function ertraege(string $von, string $bis, ?int $kontoId = null): array
|
||
{
|
||
$params = [Mandant::id(), $von, $bis];
|
||
$sql = "SELECT w.name, w.isin, SUM(d.betrag - d.gebuehren - d.steuern) AS summe,
|
||
COUNT(*) AS anzahl
|
||
FROM depot_buchungen d
|
||
JOIN wertpapiere w ON w.id = d.wertpapier_id
|
||
WHERE d.benutzer_id = ? AND d.vorgang = 'dividende'
|
||
AND d.datum BETWEEN ? AND ?";
|
||
|
||
if ($kontoId !== null) {
|
||
$sql .= ' AND d.konto_id = ?';
|
||
$params[] = $kontoId;
|
||
}
|
||
|
||
$zeilen = Database::fetchAll($sql . ' GROUP BY w.id ORDER BY summe DESC', $params);
|
||
|
||
$summe = 0.0;
|
||
foreach ($zeilen as $nummer => $zeile) {
|
||
$zeilen[$nummer]['summe'] = round((float)$zeile['summe'], 2);
|
||
$summe += $zeilen[$nummer]['summe'];
|
||
}
|
||
|
||
return ['zeilen' => $zeilen, 'summe' => round($summe, 2)];
|
||
}
|
||
|
||
/**
|
||
* Die Wertentwicklung eines Depots über die letzten Monate: je Monatsende
|
||
* der bewertete Bestand.
|
||
*
|
||
* @return list<array{monat: string, kuerzel: string, wert: float, einstand: float}>
|
||
*/
|
||
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'], $wertpapierId);
|
||
|
||
$reihe[] = [
|
||
'monat' => sprintf('%04d-%02d', $monat['jahr'], $monat['monat']),
|
||
'kuerzel' => $monat['kuerzel'],
|
||
'wert' => $bestand['wert'],
|
||
'einstand' => $bestand['einstand'],
|
||
];
|
||
}
|
||
|
||
return $reihe;
|
||
}
|
||
|
||
/**
|
||
* Alle Depotbuchungen bis zum Stichtag, aufsteigend – die Reihenfolge
|
||
* entscheidet über den gleitenden Durchschnitt.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
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
|
||
FROM depot_buchungen d
|
||
JOIN wertpapiere w ON w.id = d.wertpapier_id
|
||
JOIN konten k ON k.id = d.konto_id
|
||
WHERE d.benutzer_id = ? AND d.datum <= ?';
|
||
|
||
if ($kontoId !== null) {
|
||
$sql .= ' AND d.konto_id = ?';
|
||
$params[] = $kontoId;
|
||
}
|
||
|
||
if ($wertpapierId !== null) {
|
||
$sql .= ' AND d.wertpapier_id = ?';
|
||
$params[] = $wertpapierId;
|
||
}
|
||
|
||
return Database::fetchAll($sql . ' ORDER BY d.datum, d.id', $params);
|
||
}
|
||
}
|