Damit ist der geplante Funktionsumfang beisammen. Phase 4 abgeschlossen - Konfigurierbare Cockpit-Kacheln; abgewählte werden nicht berechnet - Einnahmen/Ausgaben je Monat als Balkenpaare mit Saldolinie, aufgesetzt auf den Summenbericht statt auf eine eigene Abfrage - Meine Ausgaben als Ring auf der obersten Kategorieebene - Offene Vorgänge aus allen Modulen Phase 5 – Online-Banking - nemiah/php-fints als Fork auf Codeberg, psr/log direkt eingebaut: die Anwendung hängt an keinem fremden Update-Zyklus - PIN AES-256-GCM-verschlüsselt; Schlüssel aus APP_KEY oder aus data/schluessel/app_key, den der Entrypoint einmalig erzeugt - TAN-Dialog über mehrere Requests (FinTs::persist plus serialisierte Aktion in der Sitzung) – ein wartender Prozess wäre mit php-fpm der falsche Weg - Abgleich statt Direktbuchung: verbinden, übernehmen oder verwerfen - Regeln mit vier Vergleichsarten, Betragsgrenzen und Split-Schema Phase 6 – Wertpapiere - Depotbuchung und Geldbuchung als zwei Seiten eines Geschäfts; betrag ist der Bruttowert, Nebenkosten stehen daneben - Einstand nach gleitendem Durchschnitt, realisierte Gewinne, Erträge - Kursabruf über Leeway oder FMP, täglicher Lauf per Cron - QIF-Import für !Type:Invst Dabei behoben: Der Import buchte den Geldfluss jedes Wertpapiergeschäfts als Abgang – auch bei Verkäufen und Dividenden; und er erkannte das Verrechnungskonto nicht, wenn dem Konto im L-Feld eine Kategorie vorangeht (bei Verkäufen also immer). Phase 7 – Nebenmodule und Zahlungsverkehr - Buchungsvorlagen inklusive QIF-Import, Splits werden mitskaliert - Regelmäßige Buchungen mit Fälligkeitslauf, Finanzkalender, Erinnerungen - Adressen mit IBAN-Prüfung, Verträge mit Kündigungsfrist, Sparziele - Kredite mit gerechnetem Tilgungsplan - Überweisungen über FinTS; die Buchung entsteht erst nach Annahme durch die Bank. Die pain.001-Nachricht erzeugt SepaXml selbst Nebenbei: .dockerignore ergänzt (ohne sie brach der Build an data/mariadb ab), PlatzhalterController entfernt, zwei Inline-Handler beseitigt, die die Content-Security-Policy ohnehin blockierte. 888 PHP-Prüfungen und 45 E2E-Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
307 lines
10 KiB
PHP
307 lines
10 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager\Services;
|
||
|
||
use Finanzmanager\Models\Einstellung;
|
||
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.
|
||
*
|
||
* Der Anbieter ist austauschbar: Jeder liefert zu einem Wertpapier einen Kurs
|
||
* oder null, alles andere – Reihenfolge, Ablage, Protokoll – ist gemeinsam.
|
||
*
|
||
* Die Kurse sind Tagesschlusskurse beziehungsweise um 15–20 Minuten verzögerte
|
||
* Realtime-Werte. Für eine Vermögensübersicht genügt das; wer auf den Cent
|
||
* genaue Momentaufnahmen braucht, ist bei einem Finanzmanager ohnehin falsch.
|
||
*/
|
||
class Kursabruf
|
||
{
|
||
public const ANBIETER = [
|
||
'leeway' => 'Leeway.tech (50 Abrufe/Tag)',
|
||
'fmp' => 'Financial Modeling Prep (250 Abrufe/Tag)',
|
||
];
|
||
|
||
/** Wie lange auf den Kursdienst gewartet wird, in Sekunden. */
|
||
private const ZEITGRENZE = 15;
|
||
|
||
/**
|
||
* Holt Kurse für alle Wertpapiere mit Bestand.
|
||
*
|
||
* @param list<array<string,mixed>>|null $wertpapiere sonst alle aktiven mit Bestand
|
||
* @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list<string>}
|
||
*/
|
||
public static function laufen(?array $wertpapiere = null): array
|
||
{
|
||
$anbieter = Einstellung::get('wertpapiere_kursdienst', 'leeway') ?? 'leeway';
|
||
$key = (string)Einstellung::get('wertpapiere_api_key', '');
|
||
|
||
$protokoll = [];
|
||
$abgerufen = 0;
|
||
$fehler = 0;
|
||
$uebersprungen = 0;
|
||
|
||
if (!array_key_exists($anbieter, self::ANBIETER)) {
|
||
return self::ergebnis(0, 1, 0, ['Unbekannter Kursdienst: ' . $anbieter]);
|
||
}
|
||
if ($key === '') {
|
||
return self::ergebnis(0, 1, 0, [
|
||
'Kein API-Schlüssel hinterlegt (Einstellungen → Wertpapiere).',
|
||
]);
|
||
}
|
||
|
||
$wertpapiere ??= self::mitBestand();
|
||
$heute = date('Y-m-d');
|
||
|
||
foreach ($wertpapiere as $wertpapier) {
|
||
$name = (string)$wertpapier['name'];
|
||
|
||
// Was heute schon einen Kurs hat, wird nicht erneut abgefragt – das
|
||
// Freikontingent ist knapp, und mehr als einen Tageskurs speichert
|
||
// die Anwendung ohnehin nicht.
|
||
$vorhanden = Wertpapier::letzterKurs((int)$wertpapier['id'], $heute);
|
||
if ($vorhanden !== null && (string)$vorhanden['datum'] === $heute) {
|
||
$uebersprungen++;
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$kurs = self::kurs($anbieter, $key, $wertpapier);
|
||
} catch (\Throwable $e) {
|
||
$fehler++;
|
||
$protokoll[] = $name . ': ' . $e->getMessage();
|
||
continue;
|
||
}
|
||
|
||
if ($kurs === null) {
|
||
$fehler++;
|
||
$protokoll[] = $name . ': kein Kurs geliefert.';
|
||
continue;
|
||
}
|
||
|
||
Wertpapier::kursSetzen(
|
||
(int)$wertpapier['id'],
|
||
$heute,
|
||
$kurs,
|
||
$anbieter,
|
||
(string)($wertpapier['waehrung'] ?: 'EUR')
|
||
);
|
||
|
||
$abgerufen++;
|
||
$protokoll[] = $name . ': ' . number_format($kurs, 2, ',', '.');
|
||
}
|
||
|
||
return self::ergebnis($abgerufen, $fehler, $uebersprungen, $protokoll);
|
||
}
|
||
|
||
/**
|
||
* Wertpapiere, die überhaupt einen Kurs brauchen: solche mit Bestand.
|
||
* Verkaufte Papiere kosten sonst jeden Tag Abrufe, ohne dass ihr Kurs
|
||
* irgendwo auftauchte.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
public static function mitBestand(): array
|
||
{
|
||
$bestand = Depot::bestand();
|
||
$ids = array_column($bestand['positionen'], 'wertpapier_id');
|
||
|
||
return array_values(array_filter(
|
||
Wertpapier::alle(true),
|
||
static fn(array $wertpapier): bool => in_array((int)$wertpapier['id'], $ids, true)
|
||
));
|
||
}
|
||
|
||
/**
|
||
* Ein einzelner Kurs.
|
||
*
|
||
* @param array<string,mixed> $wertpapier
|
||
*/
|
||
public static function kurs(string $anbieter, string $key, array $wertpapier): ?float
|
||
{
|
||
return match ($anbieter) {
|
||
'fmp' => self::fmp($key, $wertpapier),
|
||
default => self::leeway($key, $wertpapier),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Leeway.tech kennt ISINs direkt: Die Stammdatenabfrage liefert das Kürzel,
|
||
* mit dem der Kurs geholt wird. Ist das Kürzel am Wertpapier hinterlegt,
|
||
* entfällt der erste Aufruf – das halbiert den Verbrauch.
|
||
*
|
||
* @param array<string,mixed> $wertpapier
|
||
*/
|
||
private static function leeway(string $key, array $wertpapier): ?float
|
||
{
|
||
$symbol = trim((string)$wertpapier['symbol']);
|
||
|
||
if ($symbol === '') {
|
||
$isin = trim((string)$wertpapier['isin']);
|
||
if ($isin === '') {
|
||
throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.');
|
||
}
|
||
|
||
$stamm = self::hole(
|
||
'https://api.leeway.tech/api/v1/public/general/isin/' . rawurlencode($isin)
|
||
. '?apitoken=' . rawurlencode($key)
|
||
);
|
||
$symbol = (string)self::ersterWert($stamm, ['Code', 'code', 'Ticker', 'ticker', 'symbol']);
|
||
|
||
if ($symbol === '') {
|
||
throw new \RuntimeException('Zur ISIN ' . $isin . ' liefert der Dienst kein Kürzel.');
|
||
}
|
||
|
||
// Gefundene Kürzel werden gemerkt, damit der Umweg einmalig bleibt.
|
||
Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol]));
|
||
}
|
||
|
||
$antwort = self::hole(
|
||
'https://api.leeway.tech/api/v1/public/realtime/' . rawurlencode($symbol)
|
||
. '?apitoken=' . rawurlencode($key)
|
||
);
|
||
|
||
return self::zahl(self::ersterWert($antwort, ['close', 'price', 'last', 'previousClose']));
|
||
}
|
||
|
||
/**
|
||
* Financial Modeling Prep sucht die ISIN erst zu einem Kürzel, dann kommt
|
||
* der Kurs über die Kurzabfrage.
|
||
*
|
||
* @param array<string,mixed> $wertpapier
|
||
*/
|
||
private static function fmp(string $key, array $wertpapier): ?float
|
||
{
|
||
$symbol = trim((string)$wertpapier['symbol']);
|
||
|
||
if ($symbol === '') {
|
||
$isin = trim((string)$wertpapier['isin']);
|
||
if ($isin === '') {
|
||
throw new \RuntimeException('Weder Kürzel noch ISIN hinterlegt.');
|
||
}
|
||
|
||
$treffer = self::hole(
|
||
'https://financialmodelingprep.com/api/v3/search?query=' . rawurlencode($isin)
|
||
. '&limit=1&apikey=' . rawurlencode($key)
|
||
);
|
||
$symbol = (string)self::ersterWert($treffer[0] ?? [], ['symbol', 'Symbol']);
|
||
|
||
if ($symbol === '') {
|
||
throw new \RuntimeException('Zur ISIN ' . $isin . ' findet der Dienst kein Kürzel.');
|
||
}
|
||
|
||
Wertpapier::speichern((int)$wertpapier['id'], array_merge($wertpapier, ['symbol' => $symbol]));
|
||
}
|
||
|
||
$antwort = self::hole(
|
||
'https://financialmodelingprep.com/api/v3/quote-short/' . rawurlencode($symbol)
|
||
. '?apikey=' . rawurlencode($key)
|
||
);
|
||
|
||
return self::zahl(self::ersterWert($antwort[0] ?? $antwort, ['price', 'Price']));
|
||
}
|
||
|
||
/**
|
||
* Ein GET gegen den Kursdienst, Antwort als Feld.
|
||
*
|
||
* @return array<mixed>
|
||
*/
|
||
private static function hole(string $url): array
|
||
{
|
||
$kanal = curl_init($url);
|
||
if ($kanal === false) {
|
||
throw new \RuntimeException('Verbindung konnte nicht aufgebaut werden.');
|
||
}
|
||
|
||
curl_setopt_array($kanal, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => self::ZEITGRENZE,
|
||
CURLOPT_CONNECTTIMEOUT => 5,
|
||
CURLOPT_FOLLOWLOCATION => false,
|
||
CURLOPT_USERAGENT => 'Finanzmanager Kursabruf',
|
||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||
]);
|
||
|
||
$antwort = curl_exec($kanal);
|
||
$status = (int)curl_getinfo($kanal, CURLINFO_RESPONSE_CODE);
|
||
$fehler = curl_error($kanal);
|
||
curl_close($kanal);
|
||
|
||
// 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;
|
||
DebugLog::schreibe('kursabruf', 'GET ' . $sichtbar . ' → ' . $status);
|
||
|
||
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 . ').');
|
||
}
|
||
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 . '.');
|
||
}
|
||
|
||
$daten = json_decode((string)$antwort, true);
|
||
if (!is_array($daten)) {
|
||
throw new \RuntimeException('Antwort des Kursdienstes ist kein JSON.');
|
||
}
|
||
|
||
return $daten;
|
||
}
|
||
|
||
/**
|
||
* Der erste belegte der genannten Schlüssel.
|
||
*
|
||
* Mehrere Namen, weil die Dienste ihre Felder unterschiedlich benennen und
|
||
* gelegentlich umbenennen – ein fehlender Kurs soll nicht am Feldnamen
|
||
* scheitern.
|
||
*
|
||
* @param array<mixed> $daten
|
||
* @param list<string> $schluessel
|
||
*/
|
||
private static function ersterWert(array $daten, array $schluessel): mixed
|
||
{
|
||
foreach ($schluessel as $name) {
|
||
if (isset($daten[$name]) && $daten[$name] !== '' && $daten[$name] !== null) {
|
||
return $daten[$name];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static function zahl(mixed $wert): ?float
|
||
{
|
||
if ($wert === null || !is_numeric($wert)) {
|
||
return null;
|
||
}
|
||
|
||
$kurs = round((float)$wert, 6);
|
||
|
||
return $kurs > 0.0 ? $kurs : null;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $protokoll
|
||
* @return array{abgerufen: int, fehler: int, uebersprungen: int, protokoll: list<string>}
|
||
*/
|
||
private static function ergebnis(int $abgerufen, int $fehler, int $uebersprungen, array $protokoll): array
|
||
{
|
||
return [
|
||
'abgerufen' => $abgerufen,
|
||
'fehler' => $fehler,
|
||
'uebersprungen' => $uebersprungen,
|
||
'protokoll' => $protokoll,
|
||
];
|
||
}
|
||
}
|