Fix historical crypto settlement calculations
All checks were successful
Deploy / deploy-staging (push) Successful in 34s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-09-13 00:18:43 +02:00
parent 72b7202a8a
commit d0dd38823d
7 changed files with 121 additions and 14 deletions

View File

@@ -386,6 +386,42 @@ final class FxRatesRepository
}
}
public function replaceFetchRates(int $fetchId, array $rates): void
{
if ($fetchId <= 0) {
return;
}
$normalizedRates = [];
foreach ($rates as $currencyCode => $rate) {
$currencyCode = strtoupper(trim((string) $currencyCode));
if ($currencyCode !== '' && is_numeric($rate) && (float) $rate > 0) {
$normalizedRates[$currencyCode] = (float) $rate;
}
}
$this->pdo->prepare('DELETE FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id')
->execute(['fetch_id' => $fetchId]);
if ($normalizedRates === []) {
return;
}
$placeholders = [];
$params = ['fetch_id' => $fetchId];
$index = 0;
foreach ($normalizedRates as $currencyCode => $rate) {
$codeKey = 'currency_code_' . $index;
$valueKey = 'current_value_' . $index;
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
$params[$codeKey] = $currencyCode;
$params[$valueKey] = $rate;
$index++;
}
$this->pdo->prepare(
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
)->execute($params);
}
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));

View File

@@ -2319,6 +2319,29 @@
}
}
async function repairHistoricalRecoveryFx() {
setSaving(true);
setError('');
setMessage('');
try {
await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/historical-recovery-fx`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
measured_at: recoveryForm.miner_at,
doge_usd: recoveryForm.doge_usd,
usd_eur: recoveryForm.usd_eur,
}),
});
await loadBootstrap(projectKey);
setMessage('Historischer FX-Snapshot und zugehoeriger Miner wurden korrigiert.');
} catch (err) {
setError(`Historischer FX-Snapshot konnte nicht korrigiert werden: ${err.message}`);
} finally {
setSaving(false);
}
}
async function submitWalletSnapshotFromPreview(previewOverride) {
const preview = normalizeOcrPreview(previewOverride || ocrPreview);
const raw = {
@@ -3334,7 +3357,6 @@
value: earnedValue !== null ? fmtMoney(earnedValue, reportCurrency) : 'n/a',
sub: [
totalHoldingsValue !== null ? `Variabel ${fmtMoney(totalHoldingsValue, reportCurrency)}` : null,
settledCryptoSpendValue !== null ? `Fix ${fmtMoney(settledCryptoSpendValue, reportCurrency)}` : null,
currentSettings?.baseline_measured_at ? `Seit Baseline ${fmtDate(currentSettings.baseline_measured_at)}` : 'Benötigt Baseline',
].filter(Boolean).join(' · '),
}),
@@ -3487,6 +3509,12 @@
className: 'mc-button mc-button--primary',
disabled: saving,
}, saving ? 'Speichert …' : 'Alle Daten nachtragen'),
h('button', {
type: 'button',
className: 'mc-button mc-button--ghost',
onClick: repairHistoricalRecoveryFx,
disabled: saving,
}, 'Nur historischen FX/Miner korrigieren'),
])),
]);
}

View File

@@ -142,7 +142,7 @@ Diese Regeln sind bei jeder Erweiterung der Wallet-, Miner- oder Uebersichtslogi
4. Wird ein Miner mit der Kryptowaehrung bezahlt, reduziert der Kauf den Walletbestand um den gezahlten Coin-Betrag. Gleichzeitig wird dessen Gegenwert zum Kaufzeitpunkt als feste Buchung gespeichert. Dieser Betrag darf durch spaetere Kursaenderungen nicht mehr beeinflusst werden.
5. Ein Wallet-Screenshot ist fuer seinen Zeitstempel autoritativ. Sein erkannter Hauptbestand ueberschreibt die rechnerische Wallet-Historie bis zu diesem Zeitpunkt; erst danach werden weitere Transfers, Krypto-Minerkaeufe und externe Wallet-Auszahlungen verrechnet.
6. Jede erfasste Auszahlung aus dem Mining-Tool muss den Walletbestand automatisch fortschreiben. Externe Auszahlungen aus dem hier verfolgten Wallet reduzieren ihn.
7. Der angezeigte bisherige Wert trennt variable Werte (Coins im Mining-Tool und Wallet zum aktuellen Kurs) von fixen Werten (bereits mit Krypto bezahlte Miner zum Ausgabezeitpunkt). Die Gesamtsumme besteht aus beiden Kategorien.
7. `Bisher verdient gesamt` zeigt ausschliesslich variable Werte: Coins im Mining-Tool und Wallet zum aktuellen Kurs. Bereits mit Krypto bezahlte Miner sind Ausgaben und duerfen dort niemals als fixer Ertrag erscheinen.
8. Jede neue feste Krypto-Buchung referenziert die `fetch_id` des Moduls `fx-rates`. Der Mining-Checker fuehrt keine eigene Kurstabelle. Umrechnungen in andere Berichtswährungen verwenden fuer fixe Werte denselben historischen API-Snapshot.
9. Wird ein gemieteter Miner geloescht, wird sein kompletter Datensatz entfernt: Mietkosten, feste Krypto-Bewertung, FX-Referenz und der daraus abgeleitete Wallet-Abzug entfallen. Der Miner beeinflusst danach weder Walletbestand noch Ausgaben, Reinvest, Hashrate oder Break-even. Basis-Angebote, Mining-Uploads und andere Wallet-Buchungen bleiben unveraendert.
10. `Kosten/kH/s/Tag` ist ein laufzeitbereinigter Vergleichswert: feste historische Gesamtkosten geteilt durch die gesamte Hashrate einschliesslich Bonus und die exakten Kalendertage zwischen Mietzeitpunkt und Laufzeitende. Das Laufzeitende entsteht durch das Addieren der gebuchten Kalendermonate; bei kuerzeren Zielmonaten wird auf deren letzten Kalendertag begrenzt. Fuer Krypto-Mieten sind das `settled_value_amount / (Basis-Hashrate + Bonus-Hashrate) / Laufzeittage` in `settled_value_currency`; der aktuelle Coin-Kurs und ein Angebots-Referenzpreis duerfen diese Kennzahl nicht beeinflussen. Fuer FIAT-Mieten gilt derselbe Quotient mit dem tatsaechlich gezahlten FIAT-Betrag und dessen Zahlungswaehrung.
@@ -175,6 +175,8 @@ Fehlt ein exakter historischer FX-Snapshot, darf eine feste Krypto-Ausgabe nicht
Fuer zusammenhaengende, belegbare Ausfaelle steht der Bereich `Nachtragen` bereit. Er legt zuerst einen historischen `fx-rates`-Snapshot an und speichert danach in fester Reihenfolge Krypto-Miete, Mining-Tool-Transfer, Wallet-Snapshot und Mining-Messpunkt. Wiederholte Ausfuehrung des Assistenten verwendet den FX-Snapshot mit identischem Zeitpunkt erneut. Die fachlichen Buchungen selbst duerfen nach einer erfolgreichen Ausfuehrung nicht erneut gestartet werden, damit keine doppelten Miner oder Transfers entstehen.
Kursnotierung und Speicherung werden getrennt behandelt: Die Eingabe `DOGE/USD` ist USD pro DOGE. Ein `fx-rates`-Snapshot mit Basis USD speichert dagegen DOGE pro USD, also den Kehrwert. Der Nachtrag-Assistent erledigt diese Umrechnung vor dem Speichern. `Nur historischen FX/Miner korrigieren` repariert ausschliesslich diesen Snapshot und die feste Bewertung eines bereits angelegten passenden Miners; Transfers und Messpunkte werden dabei nicht erneut angelegt.
## Break-even
Der Gesamt-Break-even verwendet die Summe aus Fiat-Investitionen und `Reinvest fix` gegen aktuelle, noch gehaltene Miner- und Walletbestaende. Krypto-Reinvestitionen muessen immer den historischen festen Gegenwert aus dem zugeordneten `fx-rates`-Snapshot verwenden; eine Bewertung zum aktuellen Coin-Kurs ist unzulässig.

View File

@@ -854,11 +854,16 @@ final class Router
$measuredAt = $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey));
$dogeUsd = $this->requiredDecimal($input['doge_usd'] ?? null, 'doge_usd');
$usdEur = $this->requiredDecimal($input['usd_eur'] ?? null, 'usd_eur');
// fx-rates stores target units per base unit. DOGE/USD is supplied as USD per DOGE.
$rates = ['DOGE' => 1 / $dogeUsd, 'EUR' => $usdEur];
$existing = $fxRepository->findFetchByBaseAndFetchedAt('USD', $measuredAt);
if (is_array($existing) && is_numeric($existing['id'] ?? null)) {
$fetchId = (int) $existing['id'];
$fxRepository->replaceFetchRates($fetchId, $rates);
$this->repairHistoricalRecoveryMinerSettlement($projectKey, $measuredAt, $dogeUsd, $usdEur, $fetchId);
return [
'fetch_id' => (int) $existing['id'],
'fetch_id' => $fetchId,
'reused' => true,
'fetched_at' => $measuredAt,
];
@@ -868,19 +873,39 @@ final class Router
'USD',
'coingecko+valutafx',
substr($measuredAt, 0, 10),
['DOGE' => $dogeUsd, 'EUR' => $usdEur],
$rates,
$measuredAt,
'migration'
);
$fetchId = is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : null;
if ($fetchId !== null) {
$this->repairHistoricalRecoveryMinerSettlement($projectKey, $measuredAt, $dogeUsd, $usdEur, $fetchId);
}
return [
'fetch_id' => is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : null,
'fetch_id' => $fetchId,
'reused' => false,
'fetched_at' => $measuredAt,
'provider' => 'coingecko+valutafx',
];
}
private function repairHistoricalRecoveryMinerSettlement(string $projectKey, string $purchasedAt, float $dogeUsd, float $usdEur, int $fxFetchId): void
{
foreach ($this->repository()->listPurchasedMiners($projectKey) as $miner) {
if ((string) ($miner['purchased_at'] ?? '') !== $purchasedAt || strtoupper((string) ($miner['currency'] ?? '')) !== 'DOGE') {
continue;
}
$spent = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null;
$minerId = is_numeric($miner['id'] ?? null) ? (int) $miner['id'] : 0;
if ($spent === null || $spent <= 0 || $minerId <= 0) {
continue;
}
$this->repository()->updatePurchasedMinerSettlement($projectKey, $minerId, $spent * $dogeUsd * $usdEur, 'EUR', $fxFetchId);
}
}
private function offerBasisHistory(string $projectKey): array
{
return $this->repository()->listOfferBasisHistory($projectKey, 50);

View File

@@ -461,15 +461,11 @@ final class AnalyticsService
// Reinvestments are spent coins, not a current crypto holding. Their historical
// settlement value is therefore the only valid break-even display basis.
$reinvestedCapital = $settledCryptoSpendValue;
$settledCryptoSpendCurrentAsset = $latestAsset !== ''
? $this->settledCryptoSpendValue($purchasedMiners, $latestAsset, $latest)
: null;
$earnedCurrentAsset = $settledCryptoSpendCurrentAsset !== null
? $holdingsCurrentAsset + $settledCryptoSpendCurrentAsset
: $holdingsCurrentAsset;
$earnedValue = ($totalHoldingsValue !== null || $settledCryptoSpendValue !== null)
? (float) ($totalHoldingsValue ?? 0.0) + (float) ($settledCryptoSpendValue ?? 0.0)
: null;
// Income is only the current value of coins still held in the miner or wallet.
// A crypto-paid miner is an expense and must never be added as fixed income.
$settledCryptoSpendCurrentAsset = null;
$earnedCurrentAsset = $holdingsCurrentAsset;
$earnedValue = $totalHoldingsValue;
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
$totalInvestedCapital = ($cashInvestedCapital !== null || $reinvestedCapital !== null)
? (float) ($cashInvestedCapital ?? 0.0) + (float) ($reinvestedCapital ?? 0.0)

View File

@@ -1144,6 +1144,25 @@ final class MiningRepository
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function updatePurchasedMinerSettlement(string $projectKey, int $minerId, float $amount, string $currency, int $fxFetchId): ?array
{
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->table('purchased_miners') . '
SET settled_value_amount = :amount, settled_value_currency = :currency, settled_fx_fetch_id = :fx_fetch_id
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id'
);
$stmt->execute([
'amount' => $amount,
'currency' => strtoupper(trim($currency)),
'fx_fetch_id' => $fxFetchId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $minerId,
]);
return $this->getPurchasedMiner($projectKey, $minerId);
}
public function deletePurchasedMiner(string $projectKey, int $minerId): void
{
if ($minerId <= 0) {

View File

@@ -137,6 +137,7 @@ Aus [custom/apps/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berl
- `Mining-Checker` ist eine Desktop-App aus `custom/apps/mining-checker/`
- Historische Mining-Nachtraege verwenden ihren belegten Zeitpunkt; fuer feste Krypto-Buchungen ist ein zeitnaher, referenzierter `fx-rates`-Snapshot Pflicht und ein aktueller Kurs darf keinen historischen Snapshot ersetzen.
- Der Bereich `Nachtragen` im Mining-Checker speichert zusammenhaengende historische Miner-, Transfer-, Wallet- und Mining-Daten in chronologischer Reihenfolge und legt den belegten FX-Snapshot davor an.
- `Bisher verdient` besteht ausschliesslich aus variablen Coin-Bestaenden; fixe Krypto-Mieten sind historische Ausgaben und gehoeren nicht in den Ertrag.
- `Waehrungs-Checker` ist eine Desktop-App aus `custom/apps/fx-rates/`
- die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen
- wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul