1452 lines
64 KiB
PHP
1452 lines
64 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Modules\MiningChecker\Domain;
|
|
|
|
use Modules\MiningChecker\Support\ApiException;
|
|
|
|
final class AnalyticsService
|
|
{
|
|
private ?FxService $fx;
|
|
|
|
public function __construct(?FxService $fx = null)
|
|
{
|
|
$this->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());
|
|
}
|
|
}
|