getString('chart_min_valid_price', '0'); if ($configured > 0) { return $configured; } if (app(ConfigService::class)->usesZaarboxPricing()) { return self::MIN_VALID_ZAARBOX_CHART_PRICE_RIAL; } return self::MIN_VALID_CHART_PRICE_RIAL; } /** هر تیک کران — ذخیره در prices برای نمودار (هرگز صفر ثبت نمی‌شود) */ public function recordTick(array $payload): void { $priceValue = (int) ($payload['price'] ?? 0); if ($priceValue <= self::MIN_VALID_CHART_PRICE_RIAL) { $last = Price::orderByDesc('id')->first(); if (! $last || (int) $last->price <= self::MIN_VALID_CHART_PRICE_RIAL) { return; } $priceValue = (int) $last->price; } $lastRecorded = Price::query()->orderByDesc('id')->first(); if ($lastRecorded && (int) $lastRecorded->price === $priceValue) { return; } Price::create([ 'price' => $priceValue, 'date_at' => Carbon::now()->toDateString(), 'time_at' => Carbon::now()->toTimeString(), 'user_id' => null, ]); $this->touchCandle($priceValue); } private function touchCandle(int $priceValue): void { $candle = PriceCandle::whereDate('date_at', date('Y-m-d'))->first(); if ($candle) { $candle->end = $priceValue; if ($priceValue < $candle->low) { $candle->low = $priceValue; } elseif ($priceValue > $candle->high) { $candle->high = $priceValue; } $candle->save(); return; } PriceCandle::create([ 'start' => $priceValue, 'end' => $priceValue, 'high' => $priceValue, 'low' => $priceValue, 'date_at' => date('Y-m-d'), ]); } /** * پاک‌سازی شبانه: * - قبل از ۳۱ روز: حذف * - ۷ تا ۳۱ روز: هر ۱۲ ساعت یک رکورد * - ۲۴ ساعت تا ۷ روز: هر ۳۰ دقیقه یک رکورد * - ۲۴ ساعت اخیر: نگه‌داری همه (هر ۱۰ ثانیه) */ public function pruneHistory(): array { $deletedOld = Price::query() ->where('created_at', '<', now()->subDays(31)) ->delete(); $deleted7to31 = $this->downsampleRange(now()->subDays(31), now()->subDays(7), 12 * 3600); $deleted1to7 = $this->downsampleRange(now()->subDays(7), now()->subDay(), 30 * 60); return [ 'deleted_older_than_31d' => $deletedOld, 'downsampled_7_to_31d' => $deleted7to31, 'downsampled_1_to_7d' => $deleted1to7, ]; } private function downsampleRange(Carbon $from, Carbon $to, int $bucketSeconds): int { $rows = Price::query() ->where('created_at', '>=', $from) ->where('created_at', '<', $to) ->orderBy('created_at') ->get(['id', 'created_at']); if ($rows->isEmpty()) { return 0; } $keepIds = []; $lastBucket = null; foreach ($rows as $row) { $ts = $row->created_at?->timestamp ?? 0; $bucket = (int) floor($ts / $bucketSeconds); if ($bucket !== $lastBucket) { $keepIds[] = $row->id; $lastBucket = $bucket; } else { $keepIds[count($keepIds) - 1] = $row->id; } } return Price::query() ->where('created_at', '>=', $from) ->where('created_at', '<', $to) ->whereNotIn('id', $keepIds) ->delete(); } /** @return array{DAILY: array, WEEKLY: array, MONTHLY: array} */ public function chartFromDatabase(): array { $daily = Price::query() ->where('created_at', '>=', now()->subDay()) ->orderBy('created_at') ->get(['price', 'created_at']); $dailyPoints = $this->mapValidPriceRows($daily); $weekRows = Price::query() ->where('created_at', '>=', now()->subDays(7)) ->orderBy('created_at') ->get(['id', 'price', 'created_at']); $weeklyPoints = $this->bucketRows($weekRows, 30 * 60); $monthRows = Price::query() ->where('created_at', '>=', now()->subDays(31)) ->orderBy('created_at') ->get(['id', 'price', 'created_at']); $monthlyPoints = $this->bucketRows($monthRows, 12 * 3600); return [ 'DAILY' => $dailyPoints, 'WEEKLY' => $weeklyPoints, 'MONTHLY' => $monthlyPoints, ]; } /** @param \Illuminate\Support\Collection $rows */ private function bucketRows($rows, int $bucketSeconds): array { $points = []; $lastBucket = null; foreach ($rows as $row) { $price = (int) $row->price; if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { continue; } $ts = $row->created_at?->timestamp ?? 0; $bucket = (int) floor($ts / $bucketSeconds); $point = [ 'timestamp' => $ts * 1000, 'value' => $price, ]; if ($bucket !== $lastBucket) { $points[] = $point; $lastBucket = $bucket; } else { $points[count($points) - 1] = $point; } } return $points; } /** @param \Illuminate\Support\Collection $rows */ private function mapValidPriceRows($rows): array { $points = []; foreach ($rows as $row) { $price = (int) $row->price; if ($price <= self::MIN_VALID_CHART_PRICE_RIAL) { continue; } $points[] = [ 'timestamp' => ($row->created_at ?? now())->timestamp * 1000, 'value' => $price, ]; } return $points; } /** * @param array{DAILY?: array, WEEKLY?: array, MONTHLY?: array} $prices * @return array{DAILY: array, WEEKLY: array, MONTHLY: array} */ public function sanitizeChartPrices(array $prices, ?int $minRial = null): array { $minRial ??= self::MIN_VALID_CHART_PRICE_RIAL; $minScaled = intdiv($minRial, 1000); foreach (['DAILY', 'WEEKLY', 'MONTHLY'] as $period) { if (! isset($prices[$period]) || ! is_array($prices[$period])) { $prices[$period] = []; continue; } $lastValid = null; $sanitized = []; foreach ($prices[$period] as $point) { $value = (int) ($point['value'] ?? 0); $timestamp = (int) ($point['timestamp'] ?? 0); if ($timestamp <= 0) { continue; } $isValid = $value > $minRial || ($value > $minScaled && $value <= self::MIN_VALID_CHART_PRICE_RIAL); if ($isValid) { $lastValid = $value; } elseif ($lastValid !== null) { $value = $lastValid; } else { continue; } $sanitized[] = [ 'timestamp' => $timestamp, 'value' => $value, ]; } $prices[$period] = $sanitized; } return $prices; } }