fx = $fx; } public function enrichMeasurements(array $measurements, array $settings, array $options = []): array { $fullLatestOnly = !empty($options['full_latest_only']); $baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0); $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; $preferredPriceCurrencies = array_values(array_unique(array_filter([ strtoupper(trim((string) ($settings['report_currency'] ?? ''))), 'USD', 'EUR', 'DOGE', ]))); $baselineTs = $this->utcTimestamp($baselineAt); $previous = null; $previousMeasuredTs = null; $previousIntervalRate = null; $result = []; $payoutIndex = 0; $lastPayoutTs = null; $payoutsByAsset = []; $latestPriceByCurrency = []; $lastIndex = count($measurements) - 1; foreach ($measurements as $index => $row) { $measuredTs = $this->utcTimestamp((string) $row['measured_at']); $coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE'))); $includeFullDetail = !$fullLatestOnly || $index === $lastIndex; while (isset($payouts[$payoutIndex])) { $payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? '')); if ($payoutTs <= 0 || $payoutTs > $measuredTs) { break; } $payoutAsset = strtoupper(trim((string) ($payouts[$payoutIndex]['payout_currency'] ?? $coinCurrency))); $payoutsByAsset[$payoutAsset] = ($payoutsByAsset[$payoutAsset] ?? 0.0) + (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); $lastPayoutTs = $payoutTs; $payoutIndex++; } $cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0); $visibleCoinsTotal = (float) $row['coins_total']; $effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts; $growth = $effectiveCoinsTotal - $baselineCoins; $hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0; $perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null; $perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null; $activeHashrateMh = $this->measurementHashrateMh($costPlans, $purchasedMiners, $measuredTs > 0 ? $measuredTs : null); $intervalHours = null; $intervalGrowth = null; $perHourInterval = null; $perDayInterval = null; $perHourPerMhInterval = null; $perDayPerMhInterval = null; if (is_array($previous) && $previousMeasuredTs !== null) { $intervalStartTs = $previousMeasuredTs; $intervalStartCoins = (float) ($previous['coins_total'] ?? 0.0); if ($lastPayoutTs !== null && $lastPayoutTs > $previousMeasuredTs && $lastPayoutTs <= $measuredTs) { $intervalStartTs = $lastPayoutTs; $intervalStartCoins = 0.0; } $intervalHours = max(0.0, ($measuredTs - $intervalStartTs) / 3600); $intervalGrowth = $visibleCoinsTotal - $intervalStartCoins; $perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null; $perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null; if ($perHourInterval !== null && $activeHashrateMh > 0) { $perHourPerMhInterval = $perHourInterval / $activeHashrateMh; $perDayPerMhInterval = $perDayInterval !== null ? $perDayInterval / $activeHashrateMh : null; } } $trendLabel = 'stabil'; if ($perHourInterval !== null && $previousIntervalRate !== null) { $delta = $perHourInterval - $previousIntervalRate; $threshold = max(abs($previousIntervalRate) * 0.05, 0.01); if ($delta > $threshold) { $trendLabel = 'steigend'; } elseif ($delta < -$threshold) { $trendLabel = 'fallend'; } } $rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null; $rawPriceCurrency = $row['price_currency'] ?: null; if ($rawPrice !== null && $rawPriceCurrency !== null) { $latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice; } $measurementDerivedPrices = $this->measurementDerivedPrices($row, $preferredPriceCurrencies); foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) { $latestPriceByCurrency[$derivedCurrency] = $derivedPrice; } $priceCurrency = $rawPriceCurrency !== null ? (string) $rawPriceCurrency : $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); $price = $rawPrice; if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) { $price = (float) $measurementDerivedPrices[$priceCurrency]; } if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { $price = (float) $latestPriceByCurrency[$priceCurrency]; } if ($price === null) { foreach (['USD', 'EUR'] as $fallbackCurrency) { $fxPrice = $this->convertAmount(1.0, $coinCurrency, $fallbackCurrency, $row); if ($fxPrice !== null && $fxPrice > 0) { $latestPriceByCurrency[$fallbackCurrency] = $fxPrice; } } $priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { $price = (float) $latestPriceByCurrency[$priceCurrency]; } } $effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row) : null; $currentValue = ($includeFullDetail && $price !== null) ? $visibleCoinsTotal * $price : null; $currentValueEffective = ($includeFullDetail && $price !== null) ? $effectiveCoinsTotal * $price : null; $theoreticalDailyRevenue = ($includeFullDetail && $price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; $theoreticalDailyProfit = ( $includeFullDetail && $theoreticalDailyRevenue !== null && $effectiveDailyCost !== null ) ? $theoreticalDailyRevenue - $effectiveDailyCost : null; $breakEvenPricePerCoin = ( $includeFullDetail && $effectiveDailyCost !== null && $perDayInterval !== null && $perDayInterval > 0 ) ? $effectiveDailyCost / $perDayInterval : null; $profitMarginPercent = ( $includeFullDetail && $theoreticalDailyRevenue !== null && $theoreticalDailyRevenue > 0 && $theoreticalDailyProfit !== null ) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null; $normalizedFlags = $row['ocr_flags']; if (is_string($normalizedFlags) && $normalizedFlags !== '') { $decoded = json_decode($normalizedFlags, true); $normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags]; } $result[] = array_merge($row, [ 'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6), 'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6), 'coin_currency' => $coinCurrency, 'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6), 'growth_since_baseline' => $this->roundOrNull($growth, 6), 'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4), 'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6), 'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6), 'active_hashrate_mh' => $this->roundOrNull($activeHashrateMh > 0 ? $activeHashrateMh : null, 4), 'interval_hours' => $this->roundOrNull($intervalHours, 4), 'interval_growth' => $this->roundOrNull($intervalGrowth, 6), 'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6), 'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6), 'doge_per_hour_per_mh_interval' => $this->roundOrNull($perHourPerMhInterval, 8), 'doge_per_day_per_mh_interval' => $this->roundOrNull($perDayPerMhInterval, 8), 'trend_label' => $trendLabel, 'effective_price_per_coin' => $this->roundOrNull($price, 8), 'effective_price_currency' => $priceCurrency, 'price_is_fallback' => $rawPrice === null && $price !== null, 'price_quotes' => $measurementDerivedPrices, 'current_value' => $this->roundOrNull($currentValue, 8), 'current_value_effective' => $this->roundOrNull($currentValueEffective, 8), 'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8), 'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8), 'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8), 'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8), 'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4), 'measured_date' => substr((string) $row['measured_at'], 0, 10), 'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [], ]); if ($perHourInterval !== null) { $previousIntervalRate = $perHourInterval; } $previous = $row; $previousMeasuredTs = $measuredTs > 0 ? $measuredTs : null; } return $result; } public function buildSummary(array $measurements, array $settings, array $targets, array $options = []): array { $includeOfferScenarios = !array_key_exists('include_offer_scenarios', $options) || (bool) $options['include_offer_scenarios']; $includeLongTermProjection = !array_key_exists('include_long_term_projection', $options) || (bool) $options['include_long_term_projection']; if ($measurements === []) { return [ 'latest_measurement' => null, 'baseline' => $settings, 'targets' => [], 'payouts' => [], 'miner_offers' => [], ]; } $latest = $measurements[array_key_last($measurements)]; $latestPriceByCurrency = []; foreach ($measurements as $measurement) { $quotes = is_array($measurement['price_quotes'] ?? null) ? $measurement['price_quotes'] : []; foreach ($quotes as $currency => $price) { $normalizedCurrency = strtoupper(trim((string) $currency)); if ($normalizedCurrency !== '' && is_numeric($price)) { $latestPriceByCurrency[$normalizedCurrency] = (float) $price; } } } $latestEffectiveCurrency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); if ($latestEffectiveCurrency !== '' && is_numeric($latest['effective_price_per_coin'] ?? null)) { $latestPriceByCurrency[$latestEffectiveCurrency] = (float) $latest['effective_price_per_coin']; } $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; $minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : []; $currentHashrateMh = $this->totalHashrateMh(array_merge( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners )); $offerSummary = []; foreach ($minerOffers as $offer) { $offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings); } $targetSummary = []; foreach ($targets as $target) { $currency = (string) $target['currency']; $targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null; $linkedOffer = null; if (is_numeric($target['miner_offer_id'] ?? null)) { foreach ($offerSummary as $offer) { if ((int) ($offer['id'] ?? 0) === (int) $target['miner_offer_id']) { $linkedOffer = $offer; break; } } } if (is_array($linkedOffer)) { $currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency); $targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null) ? (float) $linkedOffer['reference_price_amount'] : (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount); } $price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency, $latest); $requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null; $remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null; $remainingDays = ( $remainingDoge !== null && $latest['doge_per_day_interval'] !== null && (float) $latest['doge_per_day_interval'] > 0 ) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null; $targetEtaAt = null; if ($remainingDays !== null) { if ($remainingDays <= 0) { $targetEtaAt = (string) ($latest['measured_at'] ?? ''); } elseif (!empty($latest['measured_at'])) { try { $targetEtaAt = $this->formatUtcTimestamp( $this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400) ); } catch (\Throwable) { $targetEtaAt = null; } } } $targetSummary[] = array_merge($target, [ 'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2), 'effective_currency' => $currency, 'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null), 'linked_offer_label' => $linkedOffer['label'] ?? null, 'latest_price_for_currency' => $price, 'required_doge' => $this->roundOrNull($requiredDoge, 6), 'remaining_doge' => $this->roundOrNull($remainingDoge, 6), 'remaining_days' => $this->roundOrNull($remainingDays, 4), 'target_eta_at' => $targetEtaAt, 'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open', ]); } $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); $latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); $cashInvestedCapital = $latestCurrency !== '' ? $this->totalInvestmentBasis( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, $latestMeasuredTs, $latestCurrency, $latest, 'cash' ) : null; $reinvestedCapital = $latestCurrency !== '' ? $this->totalInvestmentBasis( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, $latestMeasuredTs, $latestCurrency, $latest, 'reinvest' ) : null; $walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs); $latestWalletSnapshot = $this->latestWalletSnapshot(is_array($options['wallet_snapshots'] ?? null) ? $options['wallet_snapshots'] : []); if (is_array($latestWalletSnapshot)) { $snapshotBalances = $this->walletSnapshotBalances($latestWalletSnapshot); if ($snapshotBalances !== []) { $walletBalances = $snapshotBalances; } } $walletBalanceCurrentAsset = (float) ($walletBalances[strtoupper(trim((string) ($latest['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')))] ?? 0.0); $holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0); $walletValue = $latestCurrency !== '' ? ( is_array($latestWalletSnapshot) ? $this->walletSnapshotValue($latestWalletSnapshot, $latestCurrency, $latest) : $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) ) : null; $currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null; $totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null) ? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0) : null; $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; $breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null) ? max(0.0, $cashInvestedCapital - $totalHoldingsValue) : $cashInvestedCapital; $breakEvenProjection = ( $cashInvestedCapital !== null && $breakEvenRemainingAmount !== null ) ? $this->projectBreakEvenDate( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, $latest, $breakEvenRemainingAmount ) : ['days' => null, 'eta' => null]; $breakEvenDaysOverall = is_numeric($breakEvenProjection['days'] ?? null) ? (float) $breakEvenProjection['days'] : null; $latestSummary = array_merge($latest, [ 'invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), 'cash_invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), 'reinvested_capital' => $this->roundOrNull($reinvestedCapital, 8), 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), 'wallet_value' => $this->roundOrNull($walletValue, 8), 'wallet_snapshot_measured_at' => is_array($latestWalletSnapshot) ? (string) ($latestWalletSnapshot['measured_at'] ?? '') : null, 'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8), 'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8), 'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4), 'break_even_eta_at' => $breakEvenProjection['eta'] ?? null, ]); if ($includeLongTermProjection) { $currentProjection = $this->projectPerformance( is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners, $latestSummary, 730 ); $latestSummary = array_merge($latestSummary, [ 'projection_days' => $currentProjection['days'], 'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8), 'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8), 'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), ]); } if ($includeOfferScenarios) { $offerSummary = array_map( fn (array $offer): array => $this->enrichOfferScenario( $offer, $latestSummary, $currentHashrateMh, is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], $purchasedMiners ), $offerSummary ); } else { $offerSummary = []; } return [ 'latest_measurement' => $latestSummary, 'baseline' => $settings, 'targets' => $targetSummary, 'payouts' => [ 'total_count' => count($payouts), 'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6), 'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6), 'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6), 'wallet_balances' => $walletBalances, 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), ], 'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), 'miner_offers' => $offerSummary, ]; } public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array { $allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label']; $allowedY = [ 'coins_total', 'price_per_coin', 'growth_since_baseline', 'doge_per_hour_since_baseline', 'doge_per_day_since_baseline', 'doge_per_hour_interval', 'doge_per_day_interval', 'current_value', 'theoretical_daily_revenue', 'theoretical_daily_profit', ]; if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) { throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]); } $filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool { if (!empty($filters['source']) && $row['source'] !== $filters['source']) { return false; } if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) { return false; } if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) { return false; } if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) { return false; } return true; })); if ($aggregation === 'none') { return array_map(static fn (array $row): array => [ 'x' => $row[$xField] ?? null, 'y' => $row[$yField] ?? null, 'row' => $row, ], $filtered); } $groups = []; foreach ($filtered as $row) { $key = (string) ($row[$xField] ?? 'unknown'); $groups[$key][] = $row; } $result = []; foreach ($groups as $key => $rows) { $values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null)); $aggregated = match ($aggregation) { 'avg' => $values === [] ? null : array_sum($values) / count($values), 'sum' => $values === [] ? null : array_sum($values), 'min' => $values === [] ? null : min($values), 'max' => $values === [] ? null : max($values), 'count' => count($rows), 'latest' => $rows[array_key_last($rows)][$yField] ?? null, default => null, }; $result[] = [ 'x' => $key, 'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6), 'points' => count($rows), ]; } return $result; } private function roundOrNull(?float $value, int $precision): ?float { return $value === null ? null : round($value, $precision); } private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float { if ($currency === null) { return null; } $dailyTotal = 0.0; $matched = false; foreach ($costPlans as $plan) { if (empty($plan['is_active'])) { continue; } $startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? '')); $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) { continue; } $runtimeDays = $runtimeMonths * 30.4375; $endTs = (int) round($startTs + ($runtimeDays * 86400)); $isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs; if (!$isCovered) { continue; } $planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays; $convertedDailyCost = $this->convertAmount( $planDailyCost, (string) ($plan['currency'] ?? ''), $currency, $fxContext ); if ($convertedDailyCost === null) { continue; } $matched = true; $dailyTotal += $convertedDailyCost; } return $matched ? $dailyTotal : null; } private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency, ?array $fxContext = null): ?float { foreach ($latestPriceByCurrency as $sourceCurrency => $price) { $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency, $fxContext); if ($converted !== null) { return $converted; } } return null; } private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string { foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) { if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) { return $preferredCurrency; } if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) { return $preferredCurrency; } } $first = array_key_first($latestPriceByCurrency); if (is_string($first)) { return $first; } $derivedFirst = array_key_first($measurementDerivedPrices); return is_string($derivedFirst) ? $derivedFirst : null; } private function measurementDerivedPrices(array $measurement, array $targetCurrencies): array { $rawPrice = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; $rawCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); if ($rawPrice === null || $rawPrice <= 0 || $rawCurrency === '') { return []; } $prices = [$rawCurrency => $rawPrice]; foreach ($targetCurrencies as $targetCurrency) { $targetCurrency = strtoupper(trim((string) $targetCurrency)); if ($targetCurrency === '' || isset($prices[$targetCurrency])) { continue; } $converted = $this->convertAmount($rawPrice, $rawCurrency, $targetCurrency, $measurement); if ($converted !== null && $converted > 0) { $prices[$targetCurrency] = $converted; } } return $prices; } private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?array $fxContext = null): ?float { if ($amount === null || $fromCurrency === null || $toCurrency === null) { return null; } $from = strtoupper(trim($fromCurrency)); $to = strtoupper(trim($toCurrency)); if ($from === '' || $to === '') { return null; } if ($from === $to) { return $amount; } if ($this->fx === null) { return null; } $fetchId = isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) ? (int) $fxContext['fx_fetch_id'] : null; $at = is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null; return $this->fx->convertAt($amount, $from, $to, $at, null, $fetchId); } private function totalHashrateMh(array $entries): float { $total = 0.0; foreach ($entries as $entry) { if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) { continue; } if (!$this->entryIsCovered($entry, null)) { continue; } $total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); $total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); } return $total; } private function measurementHashrateMh(array $costPlans, array $purchasedMiners, ?int $measurementTs = null): float { $total = 0.0; foreach ($costPlans as $plan) { if (array_key_exists('is_active', $plan) && empty($plan['is_active'])) { continue; } if (!$this->entryIsCovered($plan, $measurementTs)) { continue; } $total += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); $total += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); } foreach ($purchasedMiners as $miner) { if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { continue; } if (!$this->entryIsCovered($miner, $measurementTs, 'purchased_at')) { continue; } $total += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); $total += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); } return $total; } private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { return null; } $total = 0.0; $matched = false; foreach ($purchasedMiners as $miner) { if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { continue; } if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { continue; } $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); if ($amount === null || $amount <= 0 || $currency === '') { continue; } $converted = $this->convertAmount($amount, $currency, $target, $fxContext); if ($converted === null) { continue; } $matched = true; $total += $converted; } return $matched ? $total : null; } private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null, ?string $fundingSource = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { return null; } $total = 0.0; $matched = false; $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext); if ($fundingSource === null && $purchasedTotal !== null) { $matched = true; $total += $purchasedTotal; } foreach ($costPlans as $plan) { if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) { continue; } if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) { continue; } $amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null; $currency = strtoupper(trim((string) ($plan['currency'] ?? ''))); if ($amount === null || $amount <= 0 || $currency === '') { continue; } $converted = $this->convertAmount($amount, $currency, $target, $fxContext); if ($converted === null) { continue; } $matched = true; $total += $converted; } if ($fundingSource !== null) { foreach ($purchasedMiners as $miner) { if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { continue; } if ($this->entryFundingSource($miner) !== $fundingSource) { continue; } $amount = $this->investmentBasisAmount($miner); $currency = strtoupper(trim((string) ($miner['reference_price_currency'] ?? $miner['currency'] ?? ''))); if ($amount === null || $amount <= 0 || $currency === '') { continue; } $converted = $this->convertAmount($amount, $currency, $target, $fxContext); if ($converted === null) { continue; } $matched = true; $total += $converted; } } return $matched ? $total : null; } private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool { $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); if ($runtimeMonths <= 0) { return true; } $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); if ($startTs <= 0) { return true; } $checkTs = $measurementTs ?? time(); if ($checkTs < $startTs) { return false; } $runtimeDays = $runtimeMonths * 30.4375; $endTs = (int) round($startTs + ($runtimeDays * 86400)); return !empty($entry['auto_renew']) || $checkTs <= $endTs; } private function normalizeHashrateMh(mixed $value, mixed $unit): float { if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') { return 0.0; } $numeric = (float) $value; return match (trim($unit)) { 'MH/s' => $numeric, 'kH/s' => $numeric / 1000, default => 0.0, }; } private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array { $offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null) + $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); $paymentType = (string) ($offer['payment_type'] ?? ''); if ($paymentType === '') { $paymentType = !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) ? 'crypto' : 'fiat'; } $basePriceAmount = is_numeric($offer['base_price_amount'] ?? null) ? (float) $offer['base_price_amount'] : (is_numeric($offer['reference_price_amount'] ?? null) ? (float) $offer['reference_price_amount'] : (is_numeric($offer['usd_reference_amount'] ?? null) ? (float) $offer['usd_reference_amount'] : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); $basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? ''))); $effectivePriceCurrency = $paymentType === 'crypto' ? (string) ($settings['crypto_currency'] ?? 'DOGE') : $basePriceCurrency; $effectivePriceAmount = $basePriceAmount; if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) { $convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency, $latest); if ($convertedReference !== null && $convertedReference > 0) { $effectivePriceAmount = $convertedReference; } } $referencePriceAmount = $basePriceAmount; $referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null; $expectedDogePerDay = null; if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) { $expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh; } $offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency, $latest)) : null; $expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null) ? $expectedDogePerDay * $offerCurrencyPrice : null; $breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0) ? $effectivePriceAmount / $expectedDailyRevenue : null; $recommendation = 'keine Basis'; if ($breakEvenDays !== null) { if ($breakEvenDays <= 180) { $recommendation = 'lohnt eher'; } elseif ($breakEvenDays <= 365) { $recommendation = 'abwaegen'; } else { $recommendation = 'eher warten'; } } return array_merge($offer, [ 'payment_type' => $paymentType, 'base_price_amount' => $this->roundOrNull($basePriceAmount, 8), 'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null, 'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4), 'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8), 'effective_price_currency' => $effectivePriceCurrency, 'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8), 'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null, 'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6), 'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8), 'break_even_days' => $this->roundOrNull($breakEvenDays, 2), 'recommendation' => $recommendation, ]); } private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array { $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); $currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; $currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null; $currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null; $investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null; $effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; $expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null; $offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null; $offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? ''); $scenarioOfferCost = ( $offerPriceAmount !== null && $offerPriceCurrency !== '' && $latestCurrency !== '' ) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency, $latest) : null; $scenarioDogePerDay = ( $currentDogePerDay !== null && $expectedDogePerDay !== null ) ? ($currentDogePerDay + $expectedDogePerDay) : null; $scenarioDailyRevenue = ( $scenarioDogePerDay !== null && $effectivePricePerCoin !== null ) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null; $scenarioDailyProfit = ( $scenarioDailyRevenue !== null && $currentDailyCost !== null ) ? ($scenarioDailyRevenue - $currentDailyCost) : null; $scenarioInvestedCapital = ( $investedCapital !== null && $scenarioOfferCost !== null ) ? ($investedCapital + $scenarioOfferCost) : null; $scenarioRemainingAmount = $scenarioInvestedCapital; $scenarioBreakEvenDays = ( $scenarioInvestedCapital !== null && $scenarioDailyRevenue !== null && $scenarioDailyRevenue > 0 ) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null; $scenarioDate = null; $measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) { $scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400))); } $scenarioPurchasedMiners = $purchasedMiners; if ($scenarioOfferCost !== null && $latestCurrency !== '') { $scenarioPurchasedMiners[] = [ 'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(), 'runtime_months' => $offer['runtime_months'] ?? null, 'mining_speed_value' => $offer['mining_speed_value'] ?? null, 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, 'total_cost_amount' => $scenarioOfferCost, 'currency' => $latestCurrency, 'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0, 'is_active' => 1, ]; } $currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730); $scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730); return array_merge($offer, [ 'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null, 'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), 'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4), 'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6), 'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6), 'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8), 'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8), 'scenario_daily_profit_delta' => ( $scenarioDailyProfit !== null && $currentDailyProfit !== null ) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null, 'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8), 'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8), 'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8), 'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8), 'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4), 'scenario_break_even_date' => $scenarioDate, 'scenario_projection_days' => $scenarioProjection['days'], 'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), 'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8), 'scenario_two_year_profit_delta' => ( $scenarioProjection['profit'] !== null && $currentProjection['profit'] !== null ) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null, 'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8), 'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8), ]); } private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array { $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); if ($baseTs <= 0) { $baseTs = $this->utcTimestamp($this->currentUtcDateTime()); } if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) { return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null]; } $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; $revenue = 0.0; $cost = 0.0; $hashrateDayTotal = 0.0; foreach ($costPlans as $plan) { if (empty($plan['is_active'])) { continue; } $coveredDays = $this->entryCoveredDayCount($plan, $baseTs, $days); if ($coveredDays <= 0) { continue; } $entryHashrateMh = $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null) + $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); $hashrateDayTotal += $entryHashrateMh * $coveredDays; $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) { $runtimeDays = $runtimeMonths * 30.4375; $dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency, $latest); if ($dailyCost !== null) { $cost += $dailyCost * $coveredDays; } } } foreach ($purchasedMiners as $miner) { if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { continue; } $coveredDays = $this->entryCoveredDayCount($miner, $baseTs, $days, 'purchased_at'); if ($coveredDays <= 0) { continue; } $entryHashrateMh = $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null) + $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); $hashrateDayTotal += $entryHashrateMh * $coveredDays; $runtimeMonths = (int) ($miner['runtime_months'] ?? 0); if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) { $runtimeDays = $runtimeMonths * 30.4375; $dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency, $latest); if ($dailyCost !== null) { $cost += $dailyCost * $coveredDays; } } } $revenue = $hashrateDayTotal * $dogePerDayPerMh * $pricePerCoin; return [ 'days' => $days, 'revenue' => $revenue, 'cost' => $cost, 'profit' => $revenue - $cost, ]; } private function entryCoveredDayCount(array $entry, int $baseTs, int $days, string $startField = 'starts_at'): int { if ($days <= 0) { return 0; } $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); if ($startTs <= 0) { return $days; } $startIndex = max(0, (int) ceil(($startTs - $baseTs) / 86400)); if ($startIndex >= $days) { return 0; } if (!empty($entry['auto_renew']) || $runtimeMonths <= 0) { return $days - $startIndex; } $runtimeDays = $runtimeMonths * 30.4375; $endTs = (int) round($startTs + ($runtimeDays * 86400)); $endIndex = (int) floor(($endTs - $baseTs) / 86400); $endIndex = min($days - 1, $endIndex); if ($endIndex < $startIndex) { return 0; } return $endIndex - $startIndex + 1; } private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array { $balances = []; foreach ($payouts as $payout) { $payoutTs = $this->utcTimestamp((string) ($payout['payout_at'] ?? '')); if ($measurementTs > 0 && $payoutTs > $measurementTs) { continue; } $currency = strtoupper(trim((string) ($payout['payout_currency'] ?? ''))); $amount = is_numeric($payout['coins_amount'] ?? null) ? (float) $payout['coins_amount'] : null; if ($currency === '' || $amount === null) { continue; } $balances[$currency] = ($balances[$currency] ?? 0.0) + $amount; } foreach ($purchasedMiners as $miner) { $purchaseTs = $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')); if ($measurementTs > 0 && $purchaseTs > $measurementTs) { continue; } if ($this->entryFundingSource($miner) !== 'reinvest') { continue; } $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; if ($currency === '' || $amount === null) { continue; } $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; } ksort($balances); return array_map(fn (float $value): float => round($value, 8), $balances); } private function latestWalletSnapshot(array $walletSnapshots): ?array { foreach ($walletSnapshots as $snapshot) { if (is_array($snapshot)) { return $snapshot; } } return null; } private function walletSnapshotBalances(array $snapshot): array { $balances = []; $rawBalances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; foreach ($rawBalances as $code => $asset) { $normalizedCode = strtoupper(trim((string) $code)); if ($normalizedCode === '') { continue; } $balance = is_array($asset) ? ($asset['balance'] ?? null) : $asset; if (!is_numeric($balance)) { continue; } $balances[$normalizedCode] = round((float) $balance, 8); } ksort($balances); return $balances; } private function walletSnapshotValue(array $snapshot, string $targetCurrency, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '') { return null; } $balances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; if ($balances === []) { return null; } $total = 0.0; $matched = false; foreach ($balances as $code => $asset) { $currency = strtoupper(trim((string) $code)); if ($currency === '') { continue; } $balance = is_array($asset) ? ($asset['balance'] ?? null) : $asset; if (!is_numeric($balance)) { continue; } $numericBalance = (float) $balance; if ($currency === $target) { $total += $numericBalance; $matched = true; continue; } $snapshotPrice = is_array($asset) && is_numeric($asset['price_amount'] ?? null) ? (float) $asset['price_amount'] : null; $snapshotPriceCurrency = is_array($asset) ? strtoupper(trim((string) ($asset['price_currency'] ?? ''))) : ''; if ($snapshotPrice !== null && $snapshotPrice > 0 && $snapshotPriceCurrency !== '') { $convertedPrice = $snapshotPriceCurrency === $target ? $snapshotPrice : $this->convertAmount($snapshotPrice, $snapshotPriceCurrency, $target, $fxContext); if ($convertedPrice !== null) { $total += $numericBalance * $convertedPrice; $matched = true; continue; } } $convertedBalance = $this->convertAmount($numericBalance, $currency, $target, $fxContext); if ($convertedBalance !== null) { $total += $convertedBalance; $matched = true; } } return $matched ? $total : null; } private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float { $target = strtoupper(trim($targetCurrency)); if ($target === '' || $balances === []) { return null; } $total = 0.0; $matched = false; foreach ($balances as $currency => $amount) { if (!is_numeric($amount)) { continue; } $numericAmount = (float) $amount; if (strtoupper((string) $currency) === $target) { $matched = true; $total += $numericAmount; continue; } $converted = $this->convertAmount($numericAmount, (string) $currency, $target, $fxContext); if ($converted === null) { continue; } $matched = true; $total += $converted; } return $matched ? $total : null; } private function entryFundingSource(array $entry): string { return !empty($entry['auto_renew']) ? 'cash' : 'reinvest'; } private function investmentBasisAmount(array $entry): ?float { if (is_numeric($entry['reference_price_amount'] ?? null)) { return (float) $entry['reference_price_amount']; } if (is_numeric($entry['base_price_amount'] ?? null)) { return (float) $entry['base_price_amount']; } if (is_numeric($entry['total_cost_amount'] ?? null)) { return (float) $entry['total_cost_amount']; } return null; } private function projectBreakEvenDate(array $costPlans, array $purchasedMiners, array $latest, float $remainingAmount): array { if ($remainingAmount <= 0) { return ['days' => 0.0, 'eta' => (string) ($latest['measured_at'] ?? null)]; } $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $baseTs <= 0) { return ['days' => null, 'eta' => null]; } $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; $maxDays = 3650; $horizonTs = $baseTs + ($maxDays * 86400); $entries = []; foreach ($costPlans as $plan) { if (empty($plan['is_active'])) { continue; } $entries[] = ['data' => $plan, 'start_field' => 'starts_at']; } foreach ($purchasedMiners as $miner) { if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { continue; } $entries[] = ['data' => $miner, 'start_field' => 'purchased_at']; } $events = [$baseTs, $horizonTs]; foreach ($entries as $entryMeta) { $entry = $entryMeta['data']; $startField = $entryMeta['start_field']; $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); if ($startTs > $baseTs && $startTs < $horizonTs) { $events[] = $startTs; } $endTs = $this->entryCoverageEndTimestamp($entry, $startField); if ($endTs !== null) { $afterEndTs = $endTs + 1; if ($afterEndTs > $baseTs && $afterEndTs < $horizonTs) { $events[] = $afterEndTs; } } } $events = array_values(array_unique(array_map('intval', $events))); sort($events, SORT_NUMERIC); $cumulativeRevenue = 0.0; for ($index = 0, $maxIndex = count($events) - 1; $index < $maxIndex; $index++) { $segmentStartTs = $events[$index]; $segmentEndTs = $events[$index + 1]; if ($segmentEndTs <= $segmentStartTs) { continue; } $segmentHashrate = 0.0; foreach ($entries as $entryMeta) { $entry = $entryMeta['data']; $startField = $entryMeta['start_field']; if (!$this->entryIsCovered($entry, $segmentStartTs, $startField)) { continue; } $segmentHashrate += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); $segmentHashrate += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); } if ($segmentHashrate <= 0) { continue; } $segmentDays = ($segmentEndTs - $segmentStartTs) / 86400; if ($segmentDays <= 0) { continue; } $segmentRevenuePerDay = $segmentHashrate * $dogePerDayPerMh * $pricePerCoin; if ($segmentRevenuePerDay <= 0) { continue; } $segmentRevenue = $segmentRevenuePerDay * $segmentDays; if ($cumulativeRevenue + $segmentRevenue >= $remainingAmount) { $remainingSegmentAmount = $remainingAmount - $cumulativeRevenue; $segmentOffsetDays = $remainingSegmentAmount / $segmentRevenuePerDay; $etaTs = (int) round($segmentStartTs + ($segmentOffsetDays * 86400)); return [ 'days' => round(($etaTs - $baseTs) / 86400, 4), 'eta' => $this->formatUtcTimestamp($etaTs), ]; } $cumulativeRevenue += $segmentRevenue; } return ['days' => null, 'eta' => null]; } private function entryCoverageEndTimestamp(array $entry, string $startField = 'starts_at'): ?int { if (!empty($entry['auto_renew'])) { return null; } $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); if ($runtimeMonths <= 0) { return null; } $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); if ($startTs <= 0) { return null; } $runtimeDays = $runtimeMonths * 30.4375; return (int) round($startTs + ($runtimeDays * 86400)); } private function utcTimestamp(?string $value): int { $normalized = trim((string) $value); if ($normalized === '') { return 0; } $utc = new \DateTimeZone('UTC'); $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM]; foreach ($formats as $format) { $date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc); if ($date instanceof \DateTimeImmutable) { return $date->getTimestamp(); } } try { return (new \DateTimeImmutable($normalized, $utc))->getTimestamp(); } catch (\Throwable) { return 0; } } private function formatUtcTimestamp(int $timestamp): string { return (new \DateTimeImmutable('@' . $timestamp)) ->setTimezone(new \DateTimeZone('UTC')) ->format('Y-m-d H:i:s'); } private function currentUtcDateTime(): string { return $this->formatUtcTimestamp(time()); } }