Fix historical crypto settlement calculations
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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'),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user