Compare commits
4 Commits
3ed4fba58c
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 127a0e71e1 | |||
| 7323673158 | |||
| 813dd86811 | |||
| 81d1e486e8 |
@@ -131,6 +131,38 @@
|
|||||||
}).format(Number(value));
|
}).format(Number(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recentMeasurementWindow(rows, windowDays) {
|
||||||
|
if (!Array.isArray(rows) || rows.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedWindowDays = Number(windowDays);
|
||||||
|
if (!Number.isFinite(normalizedWindowDays) || normalizedWindowDays <= 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedRows = rows
|
||||||
|
.filter((row) => row && row.measured_at)
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => new Date(left.measured_at).getTime() - new Date(right.measured_at).getTime());
|
||||||
|
if (sortedRows.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestTs = new Date(sortedRows[sortedRows.length - 1].measured_at).getTime();
|
||||||
|
if (!Number.isFinite(latestTs)) {
|
||||||
|
return sortedRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTs = latestTs - (normalizedWindowDays * 86400 * 1000);
|
||||||
|
const filteredRows = sortedRows.filter((row) => {
|
||||||
|
const measuredTs = new Date(row.measured_at).getTime();
|
||||||
|
return Number.isFinite(measuredTs) && measuredTs >= minTs;
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredRows.length ? filteredRows : [sortedRows[sortedRows.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
function parseStoredUtcDate(value) {
|
function parseStoredUtcDate(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
@@ -1140,10 +1172,7 @@
|
|||||||
}, 12000);
|
}, 12000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const schema = await request(`${apiBase}/projects/${encodeURIComponent(key)}/schema-status`, { timeoutMs: 4000 });
|
if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) {
|
||||||
const nextSchemaStatus = normalizeSchemaStatus(schema);
|
|
||||||
setSchemaStatus(nextSchemaStatus);
|
|
||||||
if (!nextSchemaStatus.all_present) {
|
|
||||||
setPayload(normalizeBootstrap(null, key));
|
setPayload(normalizeBootstrap(null, key));
|
||||||
setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.');
|
setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.');
|
||||||
return;
|
return;
|
||||||
@@ -1258,8 +1287,10 @@
|
|||||||
}, [payload, projectKey]);
|
}, [payload, projectKey]);
|
||||||
|
|
||||||
const overviewCharts = useMemo(() => {
|
const overviewCharts = useMemo(() => {
|
||||||
|
const overviewWindowDays = Number(payload?.bootstrap_meta?.overview_window_days || 15);
|
||||||
|
const overviewRows = recentMeasurementWindow(measurements, overviewWindowDays);
|
||||||
const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase();
|
||||||
const comparisonRows = measurements.filter((row) => {
|
const comparisonRows = overviewRows.filter((row) => {
|
||||||
const miningRate = Number(row.doge_per_hour_per_mh_interval);
|
const miningRate = Number(row.doge_per_hour_per_mh_interval);
|
||||||
const price = Number(row.effective_price_per_coin ?? row.price_per_coin);
|
const price = Number(row.effective_price_per_coin ?? row.price_per_coin);
|
||||||
return Number.isFinite(miningRate) && miningRate > 0 && Number.isFinite(price) && price > 0;
|
return Number.isFinite(miningRate) && miningRate > 0 && Number.isFinite(price) && price > 0;
|
||||||
@@ -1269,10 +1300,10 @@
|
|||||||
const basePrice = baseComparison ? Number(baseComparison.effective_price_per_coin ?? baseComparison.price_per_coin) : null;
|
const basePrice = baseComparison ? Number(baseComparison.effective_price_per_coin ?? baseComparison.price_per_coin) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mining: measurements.map((row) => ({ x: fmtDate(row.measured_at), y: row.coins_total })),
|
mining: overviewRows.map((row) => ({ x: fmtDate(row.measured_at), y: row.coins_total })),
|
||||||
performance: measurements.filter((row) => row.doge_per_day_interval !== null)
|
performance: overviewRows.filter((row) => row.doge_per_day_interval !== null)
|
||||||
.map((row) => ({ x: fmtDate(row.measured_at), y: row.doge_per_day_interval })),
|
.map((row) => ({ x: fmtDate(row.measured_at), y: row.doge_per_day_interval })),
|
||||||
pricing: measurements.filter((row) => row.price_per_coin !== null)
|
pricing: overviewRows.filter((row) => row.price_per_coin !== null)
|
||||||
.map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })),
|
.map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })),
|
||||||
miningVsPrice: baseMining && basePrice ? [
|
miningVsPrice: baseMining && basePrice ? [
|
||||||
{
|
{
|
||||||
@@ -1295,7 +1326,7 @@
|
|||||||
},
|
},
|
||||||
] : [],
|
] : [],
|
||||||
};
|
};
|
||||||
}, [currentSettings.crypto_currency, measurements]);
|
}, [currentSettings.crypto_currency, measurements, payload?.bootstrap_meta?.overview_window_days]);
|
||||||
|
|
||||||
async function submitMeasurement(fromPreview) {
|
async function submitMeasurement(fromPreview) {
|
||||||
const preview = normalizeOcrPreview(ocrPreview);
|
const preview = normalizeOcrPreview(ocrPreview);
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ final class Router
|
|||||||
'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT,
|
'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$settings = $this->safeTimed('bootstrap.settings', fn () => $this->settings($projectKey), [
|
$settings = $this->safeTimed('bootstrap.settings', fn () => $this->settings($projectKey, $this->bootstrapSettingsOptions($view)), [
|
||||||
'project_key' => $projectKey,
|
'project_key' => $projectKey,
|
||||||
'baseline_measured_at' => null,
|
'baseline_measured_at' => null,
|
||||||
'baseline_coins_total' => null,
|
'baseline_coins_total' => null,
|
||||||
@@ -358,7 +358,7 @@ final class Router
|
|||||||
'view' => $view,
|
'view' => $view,
|
||||||
'measurement_count' => is_array($measurements) ? count($measurements) : 0,
|
'measurement_count' => is_array($measurements) ? count($measurements) : 0,
|
||||||
]);
|
]);
|
||||||
$summary = $this->safeTimed('bootstrap.summary', fn () => $this->bootstrapSummary($measurements, $settings, $targets, $view), [
|
$summary = $this->safeTimed('bootstrap.summary', fn () => $this->bootstrapSummary($measurements, $settings, $targets, $walletSnapshots, $view), [
|
||||||
'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null,
|
'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null,
|
||||||
'baseline' => $settings,
|
'baseline' => $settings,
|
||||||
'targets' => [],
|
'targets' => [],
|
||||||
@@ -1147,8 +1147,14 @@ final class Router
|
|||||||
return abs($leftTs - $rightTs);
|
return abs($leftTs - $rightTs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function settings(string $projectKey): array
|
private function settings(string $projectKey, array $options = []): array
|
||||||
{
|
{
|
||||||
|
$includeCostPlans = !array_key_exists('cost_plans', $options) || (bool) $options['cost_plans'];
|
||||||
|
$includeCurrencies = !array_key_exists('currencies', $options) || (bool) $options['currencies'];
|
||||||
|
$includePayouts = !array_key_exists('payouts', $options) || (bool) $options['payouts'];
|
||||||
|
$includeMinerOffers = !array_key_exists('miner_offers', $options) || (bool) $options['miner_offers'];
|
||||||
|
$includePurchasedMiners = !array_key_exists('purchased_miners', $options) || (bool) $options['purchased_miners'];
|
||||||
|
|
||||||
$settings = $this->repository()->getSettings($projectKey);
|
$settings = $this->repository()->getSettings($projectKey);
|
||||||
$base = is_array($settings) ? $settings : [
|
$base = is_array($settings) ? $settings : [
|
||||||
'project_key' => $projectKey,
|
'project_key' => $projectKey,
|
||||||
@@ -1173,12 +1179,12 @@ final class Router
|
|||||||
$base['module_theme_accent'] = 'teal';
|
$base['module_theme_accent'] = 'teal';
|
||||||
}
|
}
|
||||||
|
|
||||||
$base['cost_plans'] = $this->costPlans($projectKey);
|
$base['cost_plans'] = $includeCostPlans ? $this->costPlans($projectKey) : [];
|
||||||
$base['currencies'] = $this->currencies();
|
$base['currencies'] = $includeCurrencies ? $this->currencies() : [];
|
||||||
$base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null);
|
$base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null);
|
||||||
$base['payouts'] = $this->payouts($projectKey);
|
$base['payouts'] = $includePayouts ? $this->payouts($projectKey) : [];
|
||||||
$base['miner_offers'] = $this->minerOffers($projectKey);
|
$base['miner_offers'] = $includeMinerOffers ? $this->minerOffers($projectKey) : [];
|
||||||
$base['purchased_miners'] = $this->purchasedMiners($projectKey);
|
$base['purchased_miners'] = $includePurchasedMiners ? $this->purchasedMiners($projectKey) : [];
|
||||||
$base['measurement_rates'] = [];
|
$base['measurement_rates'] = [];
|
||||||
return $base;
|
return $base;
|
||||||
}
|
}
|
||||||
@@ -1242,16 +1248,22 @@ final class Router
|
|||||||
'row_count' => count($rows),
|
'row_count' => count($rows),
|
||||||
'limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT,
|
'limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT,
|
||||||
]);
|
]);
|
||||||
return $this->analytics()->enrichMeasurements($rows, $settings);
|
return $this->analytics()->enrichMeasurements($rows, $settings, [
|
||||||
|
'full_latest_only' => in_array($view, ['overview', 'mining'], true),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bootstrapWalletSnapshots(string $projectKey, string $view): array
|
private function bootstrapWalletSnapshots(string $projectKey, string $view): array
|
||||||
{
|
{
|
||||||
if (!in_array($view, ['wallet'], true)) {
|
if ($view === 'wallet') {
|
||||||
|
return $this->repository()->listWalletSnapshots($projectKey, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($view, ['overview', 'mining'], true)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->repository()->listWalletSnapshots($projectKey, 50);
|
return $this->repository()->listWalletSnapshots($projectKey, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bootstrapTargets(string $projectKey, string $view): array
|
private function bootstrapTargets(string $projectKey, string $view): array
|
||||||
@@ -1270,10 +1282,16 @@ final class Router
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->measurementFxSnapshots($measurements, self::BOOTSTRAP_SNAPSHOT_LIMIT);
|
$limit = match ($view) {
|
||||||
|
'overview' => 1,
|
||||||
|
'measurements' => 10,
|
||||||
|
default => self::BOOTSTRAP_SNAPSHOT_LIMIT,
|
||||||
|
};
|
||||||
|
|
||||||
|
return $this->measurementFxSnapshots($measurements, $limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function bootstrapSummary(array $measurements, array $settings, array $targets, string $view): array
|
private function bootstrapSummary(array $measurements, array $settings, array $targets, array $walletSnapshots, string $view): array
|
||||||
{
|
{
|
||||||
if (!in_array($view, ['overview', 'mining'], true)) {
|
if (!in_array($view, ['overview', 'mining'], true)) {
|
||||||
return [
|
return [
|
||||||
@@ -1285,7 +1303,11 @@ final class Router
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->analytics()->buildSummary($measurements, $settings, $targets);
|
return $this->analytics()->buildSummary($measurements, $settings, $targets, [
|
||||||
|
'include_offer_scenarios' => $view === 'mining',
|
||||||
|
'include_long_term_projection' => $view === 'mining',
|
||||||
|
'wallet_snapshots' => $walletSnapshots,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function filterMeasurementsToRecentWindow(array $rows, int $windowDays): array
|
private function filterMeasurementsToRecentWindow(array $rows, int $windowDays): array
|
||||||
@@ -1318,6 +1340,54 @@ final class Router
|
|||||||
: 'overview';
|
: 'overview';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function bootstrapSettingsOptions(string $view): array
|
||||||
|
{
|
||||||
|
return match ($view) {
|
||||||
|
'overview' => [
|
||||||
|
'cost_plans' => true,
|
||||||
|
'currencies' => true,
|
||||||
|
'payouts' => true,
|
||||||
|
'miner_offers' => false,
|
||||||
|
'purchased_miners' => true,
|
||||||
|
],
|
||||||
|
'upload' => [
|
||||||
|
'cost_plans' => false,
|
||||||
|
'currencies' => true,
|
||||||
|
'payouts' => false,
|
||||||
|
'miner_offers' => false,
|
||||||
|
'purchased_miners' => false,
|
||||||
|
],
|
||||||
|
'measurements' => [
|
||||||
|
'cost_plans' => false,
|
||||||
|
'currencies' => false,
|
||||||
|
'payouts' => false,
|
||||||
|
'miner_offers' => false,
|
||||||
|
'purchased_miners' => false,
|
||||||
|
],
|
||||||
|
'wallet' => [
|
||||||
|
'cost_plans' => false,
|
||||||
|
'currencies' => false,
|
||||||
|
'payouts' => false,
|
||||||
|
'miner_offers' => false,
|
||||||
|
'purchased_miners' => false,
|
||||||
|
],
|
||||||
|
'dashboards' => [
|
||||||
|
'cost_plans' => false,
|
||||||
|
'currencies' => false,
|
||||||
|
'payouts' => false,
|
||||||
|
'miner_offers' => false,
|
||||||
|
'purchased_miners' => false,
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'cost_plans' => true,
|
||||||
|
'currencies' => true,
|
||||||
|
'payouts' => true,
|
||||||
|
'miner_offers' => true,
|
||||||
|
'purchased_miners' => true,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function createMeasurement(string $projectKey, array $input): array
|
private function createMeasurement(string $projectKey, array $input): array
|
||||||
{
|
{
|
||||||
$projectTimezone = $this->projectTimezone($projectKey);
|
$projectTimezone = $this->projectTimezone($projectKey);
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ final class AnalyticsService
|
|||||||
$this->fx = $fx;
|
$this->fx = $fx;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function enrichMeasurements(array $measurements, array $settings): array
|
public function enrichMeasurements(array $measurements, array $settings, array $options = []): array
|
||||||
{
|
{
|
||||||
|
$fullLatestOnly = !empty($options['full_latest_only']);
|
||||||
$baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0);
|
$baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0);
|
||||||
$baselineAt = (string) ($settings['baseline_measured_at'] ?? '');
|
$baselineAt = (string) ($settings['baseline_measured_at'] ?? '');
|
||||||
$costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [];
|
$costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [];
|
||||||
@@ -38,9 +39,11 @@ final class AnalyticsService
|
|||||||
$payoutsByAsset = [];
|
$payoutsByAsset = [];
|
||||||
$latestPriceByCurrency = [];
|
$latestPriceByCurrency = [];
|
||||||
|
|
||||||
foreach ($measurements as $row) {
|
$lastIndex = count($measurements) - 1;
|
||||||
|
foreach ($measurements as $index => $row) {
|
||||||
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
||||||
$coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')));
|
$coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')));
|
||||||
|
$includeFullDetail = !$fullLatestOnly || $index === $lastIndex;
|
||||||
while (isset($payouts[$payoutIndex])) {
|
while (isset($payouts[$payoutIndex])) {
|
||||||
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
||||||
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
||||||
@@ -134,20 +137,23 @@ final class AnalyticsService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row);
|
$effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row) : null;
|
||||||
$currentValue = $price !== null ? $visibleCoinsTotal * $price : null;
|
$currentValue = ($includeFullDetail && $price !== null) ? $visibleCoinsTotal * $price : null;
|
||||||
$currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null;
|
$currentValueEffective = ($includeFullDetail && $price !== null) ? $effectiveCoinsTotal * $price : null;
|
||||||
$theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
|
$theoreticalDailyRevenue = ($includeFullDetail && $price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
|
||||||
$theoreticalDailyProfit = (
|
$theoreticalDailyProfit = (
|
||||||
|
$includeFullDetail &&
|
||||||
$theoreticalDailyRevenue !== null &&
|
$theoreticalDailyRevenue !== null &&
|
||||||
$effectiveDailyCost !== null
|
$effectiveDailyCost !== null
|
||||||
) ? $theoreticalDailyRevenue - $effectiveDailyCost : null;
|
) ? $theoreticalDailyRevenue - $effectiveDailyCost : null;
|
||||||
$breakEvenPricePerCoin = (
|
$breakEvenPricePerCoin = (
|
||||||
|
$includeFullDetail &&
|
||||||
$effectiveDailyCost !== null &&
|
$effectiveDailyCost !== null &&
|
||||||
$perDayInterval !== null &&
|
$perDayInterval !== null &&
|
||||||
$perDayInterval > 0
|
$perDayInterval > 0
|
||||||
) ? $effectiveDailyCost / $perDayInterval : null;
|
) ? $effectiveDailyCost / $perDayInterval : null;
|
||||||
$profitMarginPercent = (
|
$profitMarginPercent = (
|
||||||
|
$includeFullDetail &&
|
||||||
$theoreticalDailyRevenue !== null &&
|
$theoreticalDailyRevenue !== null &&
|
||||||
$theoreticalDailyRevenue > 0 &&
|
$theoreticalDailyRevenue > 0 &&
|
||||||
$theoreticalDailyProfit !== null
|
$theoreticalDailyProfit !== null
|
||||||
@@ -201,8 +207,11 @@ final class AnalyticsService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildSummary(array $measurements, array $settings, array $targets): array
|
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 === []) {
|
if ($measurements === []) {
|
||||||
return [
|
return [
|
||||||
'latest_measurement' => null,
|
'latest_measurement' => null,
|
||||||
@@ -322,9 +331,22 @@ final class AnalyticsService
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
$walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs);
|
$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);
|
$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);
|
$holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0);
|
||||||
$walletValue = $latestCurrency !== '' ? $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) : null;
|
$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;
|
$currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null;
|
||||||
$totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null)
|
$totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null)
|
||||||
? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0)
|
? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0)
|
||||||
@@ -350,33 +372,41 @@ final class AnalyticsService
|
|||||||
'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6),
|
'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6),
|
||||||
'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6),
|
'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6),
|
||||||
'wallet_value' => $this->roundOrNull($walletValue, 8),
|
'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),
|
'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8),
|
||||||
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
|
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
|
||||||
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
||||||
'break_even_eta_at' => $breakEvenProjection['eta'] ?? null,
|
'break_even_eta_at' => $breakEvenProjection['eta'] ?? null,
|
||||||
]);
|
]);
|
||||||
$currentProjection = $this->projectPerformance(
|
if ($includeLongTermProjection) {
|
||||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
$currentProjection = $this->projectPerformance(
|
||||||
$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),
|
|
||||||
]);
|
|
||||||
$offerSummary = array_map(
|
|
||||||
fn (array $offer): array => $this->enrichOfferScenario(
|
|
||||||
$offer,
|
|
||||||
$latestSummary,
|
|
||||||
$currentHashrateMh,
|
|
||||||
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
$purchasedMiners
|
$purchasedMiners,
|
||||||
),
|
$latestSummary,
|
||||||
$offerSummary
|
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 [
|
return [
|
||||||
'latest_measurement' => $latestSummary,
|
'latest_measurement' => $latestSummary,
|
||||||
@@ -1111,6 +1141,101 @@ final class AnalyticsService
|
|||||||
return array_map(fn (float $value): float => round($value, 8), $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
|
private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float
|
||||||
{
|
{
|
||||||
$target = strtoupper(trim($targetCurrency));
|
$target = strtoupper(trim($targetCurrency));
|
||||||
@@ -1179,44 +1304,116 @@ final class AnalyticsService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
|
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
|
||||||
$cumulativeRevenue = 0.0;
|
|
||||||
$maxDays = 3650;
|
$maxDays = 3650;
|
||||||
|
$horizonTs = $baseTs + ($maxDays * 86400);
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
for ($day = 0; $day <= $maxDays; $day++) {
|
foreach ($costPlans as $plan) {
|
||||||
$dayHashrate = 0.0;
|
if (empty($plan['is_active'])) {
|
||||||
$dayTs = $baseTs + ($day * 86400);
|
|
||||||
foreach ($costPlans as $plan) {
|
|
||||||
if (!empty($plan['is_active']) && $this->entryIsCovered($plan, $dayTs)) {
|
|
||||||
$dayHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null);
|
|
||||||
$dayHashrate += $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'])) || !$this->entryIsCovered($miner, $dayTs, 'purchased_at')) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$dayHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null);
|
|
||||||
$dayHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dayHashrate <= 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$entries[] = ['data' => $plan, 'start_field' => 'starts_at'];
|
||||||
|
}
|
||||||
|
|
||||||
$dayRevenue = $dayHashrate * $dogePerDayPerMh * $pricePerCoin;
|
foreach ($purchasedMiners as $miner) {
|
||||||
$cumulativeRevenue += $dayRevenue;
|
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
|
||||||
if ($cumulativeRevenue >= $remainingAmount) {
|
continue;
|
||||||
$etaTs = (int) round($baseTs + ($day * 86400));
|
}
|
||||||
return [
|
$entries[] = ['data' => $miner, 'start_field' => 'purchased_at'];
|
||||||
'days' => (float) $day,
|
}
|
||||||
'eta' => $this->formatUtcTimestamp($etaTs),
|
|
||||||
];
|
$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];
|
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
|
private function utcTimestamp(?string $value): int
|
||||||
{
|
{
|
||||||
$normalized = trim((string) $value);
|
$normalized = trim((string) $value);
|
||||||
|
|||||||
Reference in New Issue
Block a user