diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5665b..726830a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `laravel-trend` will be documented in this file. +## 1.1.0 - 2025-04-18 + +- Added forecasting functionality with several methods: linear regression, moving average, weighted moving average, and exponential smoothing +- Added `forecastPeriods()` and `forecastUntil()` methods to predict future trends +- Added `isForecast` flag to TrendValue to distinguish between historical and forecasted data + ## 1.0.0 - 202X-XX-XX - initial release diff --git a/README.md b/README.md index a31aa9c..2793b24 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,57 @@ You can use the following aggregates: - `min('column')` - `count('*')` +## Forecasting + +The package supports generating forecasts based on your historical trend data. Use one of the following forecasting methods: + +```php +// Forecast 3 more months using linear regression +$trend = Trend::model(Order::class) + ->between( + start: now()->startOfYear(), + end: now()->endOfMonth(), + ) + ->perMonth() + ->forecastPeriods(3, 'linear') + ->count(); + +// Forecast until a specific date using moving average +$trend = Trend::model(Order::class) + ->between( + start: now()->subMonths(6), + end: now(), + ) + ->perMonth() + ->forecastUntil(now()->addMonths(3), 'moving-average') + ->sum('total'); +``` + +### Forecasting Methods + +The following forecasting methods are available: + +- `linear` - Uses linear regression to predict future values (default) +- `moving-average` - Uses a simple moving average of the last 3 periods +- `weighted-moving-average` - Uses a weighted moving average that gives more weight to recent data +- `exponential-smoothing` - Uses exponential smoothing with alpha = 0.3 + +### Identifying Forecasted Values + +Forecasted values are tagged with an `isForecast` property, which you can use to style or label them differently in your UI: + +```php +$trend->each(function ($value) { + if ($value->isForecast) { + // This is a forecasted value + echo "Forecast for {$value->date}: {$value->aggregate}"; + } else { + // This is a historical value + echo "Actual for {$value->date}: {$value->aggregate}"; + } +}); +``` + ## Date Column By default, laravel-trend assumes that the model on which the operation is being performed has a `created_at` date column. If your model uses a different column name for the date or you want to use a different one, you should specify it using the `dateColumn(string $column)` method. diff --git a/src/Trend.php b/src/Trend.php index 28ff762..83c0e3f 100755 --- a/src/Trend.php +++ b/src/Trend.php @@ -2,6 +2,7 @@ namespace Flowframe\Trend; +use Carbon\Carbon; use Carbon\CarbonInterface; use Carbon\CarbonPeriod; use Error; @@ -22,6 +23,12 @@ class Trend public string $dateColumn = 'created_at'; public string $dateAlias = 'date'; + + protected ?CarbonInterface $forecastStart = null; + + protected ?CarbonInterface $forecastEnd = null; + + protected string $forecastMethod = 'linear'; public function __construct(public Builder $builder) { @@ -44,6 +51,35 @@ public function between($start, $end): self return $this; } + + public function forecastUntil(CarbonInterface $end, string $method = 'linear'): self + { + $this->forecastEnd = $end; + $this->forecastMethod = $method; + + return $this; + } + + public function forecastPeriods(int $periods, string $method = 'linear'): self + { + if (!isset($this->interval)) { + throw new Error('Interval must be set before forecasting periods.'); + } + + if (!isset($this->end)) { + throw new Error('End date must be set before forecasting periods.'); + } + + $this->forecastMethod = $method; + + // The forecast will start right after the historical end date + $this->forecastStart = $this->end->copy()->add($this->interval, 1); + + // Calculate the end date based on the interval and periods + $this->forecastEnd = $this->forecastStart->copy()->add($this->interval, $periods - 1); + + return $this; + } public function interval(string $interval): self { @@ -109,7 +145,35 @@ public function aggregate(string $column, string $aggregate): Collection ->orderBy($this->dateAlias) ->get(); - return $this->mapValuesToDates($values); + $historicalData = $this->mapValuesToDates($values); + + // If forecasting is enabled, generate and append forecast data + if ($this->forecastEnd !== null) { + $forecastStart = $this->forecastStart ?? $this->end->copy()->add($this->interval, 1); + + $forecast = new TrendForecast( + $historicalData, + $this->interval, + $forecastStart, + $this->forecastEnd + ); + + $forecastData = $forecast->method($this->forecastMethod)->generate(); + + // Identify forecast data with a flag + $forecastData = $forecastData->map(function (TrendValue $value) { + return new TrendValue( + date: $value->date, + aggregate: $value->aggregate, + isForecast: true + ); + }); + + // Merge historical and forecast data + return $historicalData->merge($forecastData); + } + + return $historicalData; } public function average(string $column): Collection diff --git a/src/TrendForecast.php b/src/TrendForecast.php new file mode 100644 index 0000000..af9ca78 --- /dev/null +++ b/src/TrendForecast.php @@ -0,0 +1,200 @@ +historicalData = $historicalData; + $this->interval = $interval; + $this->forecastStart = $forecastStart; + $this->forecastEnd = $forecastEnd; + $this->dateFormat = $this->getCarbonDateFormat(); + } + + public function method(string $method): self + { + $this->method = $method; + + return $this; + } + + public function linear(): Collection + { + // Simple linear regression + $dataPoints = $this->historicalData->count(); + if ($dataPoints <= 1) { + return $this->generateEmptyForecast(); + } + + // Prepare X and Y values for regression + $xValues = range(1, $dataPoints); + $yValues = $this->historicalData->pluck('aggregate')->toArray(); + + // Calculate means + $xMean = array_sum($xValues) / $dataPoints; + $yMean = array_sum($yValues) / $dataPoints; + + // Calculate slope and intercept + $numerator = 0; + $denominator = 0; + + for ($i = 0; $i < $dataPoints; $i++) { + $numerator += ($xValues[$i] - $xMean) * ($yValues[$i] - $yMean); + $denominator += pow($xValues[$i] - $xMean, 2); + } + + // Avoid division by zero + if ($denominator == 0) { + $slope = 0; + } else { + $slope = $numerator / $denominator; + } + + $intercept = $yMean - ($slope * $xMean); + + return $this->generateForecast(function($index) use ($slope, $intercept, $dataPoints) { + // Forecast value using y = mx + b + return max(0, $intercept + $slope * ($dataPoints + $index)); + }); + } + + public function movingAverage(int $periods = 3): Collection + { + if ($this->historicalData->count() < $periods) { + return $this->generateEmptyForecast(); + } + + // Get the last n periods to calculate moving average + $lastValues = $this->historicalData->pluck('aggregate')->take(-$periods)->toArray(); + $average = array_sum($lastValues) / count($lastValues); + + return $this->generateForecast(function() use ($average) { + return $average; + }); + } + + public function weightedMovingAverage(array $weights = null): Collection + { + $dataPoints = $this->historicalData->count(); + + // Default weights based on data points, more recent = more weight + if ($weights === null) { + $weights = []; + $totalPoints = min(5, $dataPoints); + + for ($i = 1; $i <= $totalPoints; $i++) { + $weights[] = $i; + } + + // Normalize weights to sum to 1 + $sum = array_sum($weights); + $weights = array_map(function($w) use ($sum) { + return $w / $sum; + }, $weights); + } + + if ($dataPoints < count($weights)) { + return $this->generateEmptyForecast(); + } + + // Get the most recent values for the calculation + $recentValues = $this->historicalData->pluck('aggregate')->take(-count($weights))->toArray(); + + // Calculate weighted average + $weightedSum = 0; + for ($i = 0; $i < count($weights); $i++) { + $weightedSum += $recentValues[$i] * $weights[$i]; + } + + return $this->generateForecast(function() use ($weightedSum) { + return $weightedSum; + }); + } + + public function exponentialSmoothing(float $alpha = 0.3): Collection + { + if ($this->historicalData->isEmpty()) { + return $this->generateEmptyForecast(); + } + + // Get last observed value + $lastValue = $this->historicalData->last()->aggregate; + + // Calculate the EMA based on the historical data + $smoothedValue = $lastValue; + + foreach ($this->historicalData as $dataPoint) { + $smoothedValue = $alpha * $dataPoint->aggregate + (1 - $alpha) * $smoothedValue; + } + + return $this->generateForecast(function() use ($smoothedValue) { + return $smoothedValue; + }); + } + + public function generate(): Collection + { + return match ($this->method) { + 'linear' => $this->linear(), + 'moving-average' => $this->movingAverage(), + 'weighted-moving-average' => $this->weightedMovingAverage(), + 'exponential-smoothing' => $this->exponentialSmoothing(), + default => $this->linear(), + }; + } + + protected function generateEmptyForecast(): Collection + { + return $this->generateForecast(function() { + return 0; + }); + } + + protected function generateForecast(callable $valueGenerator): Collection + { + $period = CarbonPeriod::between( + $this->forecastStart, + $this->forecastEnd, + )->interval("1 {$this->interval}"); + + $forecasts = collect(); + $index = 0; + + foreach ($period as $date) { + $forecasts->push(new TrendValue( + date: $date->format($this->dateFormat), + aggregate: $valueGenerator($index), + )); + $index++; + } + + return $forecasts; + } + + protected function getCarbonDateFormat(): string + { + return match ($this->interval) { + 'minute' => 'Y-m-d H:i:00', + 'hour' => 'Y-m-d H:00', + 'day' => 'Y-m-d', + 'week' => 'Y-W', + 'month' => 'Y-m', + 'year' => 'Y', + default => 'Y-m-d', + }; + } +} \ No newline at end of file diff --git a/src/TrendValue.php b/src/TrendValue.php index 8f24c09..d97c374 100644 --- a/src/TrendValue.php +++ b/src/TrendValue.php @@ -7,6 +7,7 @@ class TrendValue public function __construct( public string $date, public mixed $aggregate, + public bool $isForecast = false, ) { } } diff --git a/tests/Feature/ForecastTest.php b/tests/Feature/ForecastTest.php new file mode 100644 index 0000000..5c270d9 --- /dev/null +++ b/tests/Feature/ForecastTest.php @@ -0,0 +1,148 @@ +historicalData = collect([ + new TrendValue(date: '2023-01', aggregate: 10), + new TrendValue(date: '2023-02', aggregate: 15), + new TrendValue(date: '2023-03', aggregate: 20), + new TrendValue(date: '2023-04', aggregate: 25), + new TrendValue(date: '2023-05', aggregate: 30), + new TrendValue(date: '2023-06', aggregate: 35), + ]); + + $this->forecastStart = Carbon::parse('2023-07-01'); + $this->forecastEnd = Carbon::parse('2023-09-01'); +}); + +it('can create a linear forecast', function () { + $forecast = new TrendForecast( + $this->historicalData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->linear(); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); // 3 months forecasted + expect($result[0]->date)->toBe('2023-07'); + expect($result[0]->aggregate)->toBeGreaterThan(35); // Should continue the trend + + // The linear forecast should have a consistent increase + $firstDiff = $result[1]->aggregate - $result[0]->aggregate; + $secondDiff = $result[2]->aggregate - $result[1]->aggregate; + expect(round($firstDiff, 2))->toBe(round($secondDiff, 2)); +}); + +it('can create a moving average forecast', function () { + $forecast = new TrendForecast( + $this->historicalData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->movingAverage(3); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); + + // The moving average of the last 3 months (25, 30, 35) should be 30 + expect($result[0]->aggregate)->toBe(30); + expect($result[1]->aggregate)->toBe(30); + expect($result[2]->aggregate)->toBe(30); +}); + +it('can create a weighted moving average forecast', function () { + $forecast = new TrendForecast( + $this->historicalData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->weightedMovingAverage([0.2, 0.3, 0.5]); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); + + // Calculate expected value: 0.2*25 + 0.3*30 + 0.5*35 = 31.5 + $expectedValue = 0.2 * 25 + 0.3 * 30 + 0.5 * 35; + expect($result[0]->aggregate)->toBe($expectedValue); +}); + +it('can create an exponential smoothing forecast', function () { + $forecast = new TrendForecast( + $this->historicalData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->exponentialSmoothing(0.3); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); + + // All forecast values should be the same + expect($result[0]->aggregate)->toBe($result[1]->aggregate); + expect($result[1]->aggregate)->toBe($result[2]->aggregate); + expect($result[0]->aggregate)->toBeGreaterThan(25); // Value should be higher than early data +}); + +it('can use the default forecasting method', function () { + $forecast = new TrendForecast( + $this->historicalData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->generate(); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); +}); + +it('can handle empty historical data', function () { + $emptyData = collect([]); + + $forecast = new TrendForecast( + $emptyData, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->generate(); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); + expect($result[0]->aggregate)->toBe(0); +}); + +it('can handle single data point for linear regression', function () { + $singlePoint = collect([ + new TrendValue(date: '2023-01', aggregate: 10), + ]); + + $forecast = new TrendForecast( + $singlePoint, + 'month', + $this->forecastStart, + $this->forecastEnd + ); + + $result = $forecast->linear(); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result)->toHaveCount(3); + expect($result[0]->aggregate)->toBe(0); +}); \ No newline at end of file diff --git a/tests/Feature/TrendForecastingIntegrationTest.php b/tests/Feature/TrendForecastingIntegrationTest.php new file mode 100644 index 0000000..b5f9539 --- /dev/null +++ b/tests/Feature/TrendForecastingIntegrationTest.php @@ -0,0 +1,157 @@ +now = Carbon::parse('2023-07-01'); + Carbon::setTestNow($this->now); + + // Mock the database connection and query builder + $this->builder = Mockery::mock(Builder::class); + $this->baseBuilder = Mockery::mock(); + + // Setup the builder to return our test values + $this->builder->shouldReceive('getConnection->getDriverName')->andReturn('mysql'); + $this->builder->shouldReceive('toBase')->andReturn($this->baseBuilder); + + // Create test data with a clear increasing trend + $this->testData = collect([ + (object) ['date' => '2023-01', 'aggregate' => 10], + (object) ['date' => '2023-02', 'aggregate' => 15], + (object) ['date' => '2023-03', 'aggregate' => 20], + (object) ['date' => '2023-04', 'aggregate' => 25], + (object) ['date' => '2023-05', 'aggregate' => 30], + (object) ['date' => '2023-06', 'aggregate' => 35], + ]); +}); + +afterEach(function () { + Mockery::close(); + Carbon::setTestNow(); +}); + +it('can forecast future periods using linear regression', function () { + // Set up mock to return test data + $this->baseBuilder->shouldReceive('selectRaw')->andReturnSelf(); + $this->baseBuilder->shouldReceive('whereBetween')->andReturnSelf(); + $this->baseBuilder->shouldReceive('groupBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('orderBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('get')->andReturn($this->testData); + + // Create trend with forecasting + $trend = Trend::query($this->builder) + ->between( + Carbon::parse('2023-01-01'), + Carbon::parse('2023-06-30') + ) + ->perMonth() + ->forecastPeriods(3, 'linear') + ->count(); + + expect($trend)->toBeInstanceOf(Collection::class); + + // Should have 6 historical + 3 forecast points + expect($trend->filter(fn ($value) => !$value->isForecast))->toHaveCount(6); + expect($trend->filter(fn ($value) => $value->isForecast))->toHaveCount(3); + + // Verify the forecast points are marked correctly + $forecasts = $trend->filter(fn ($value) => $value->isForecast)->values(); + expect($forecasts[0]->date)->toBe('2023-07'); + expect($forecasts[1]->date)->toBe('2023-08'); + expect($forecasts[2]->date)->toBe('2023-09'); + + // The forecast should continue the trend + expect($forecasts[0]->aggregate)->toBeGreaterThan(35); +}); + +it('can forecast until a specific date', function () { + // Set up mock to return test data + $this->baseBuilder->shouldReceive('selectRaw')->andReturnSelf(); + $this->baseBuilder->shouldReceive('whereBetween')->andReturnSelf(); + $this->baseBuilder->shouldReceive('groupBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('orderBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('get')->andReturn($this->testData); + + // Create trend with forecasting + $trend = Trend::query($this->builder) + ->between( + Carbon::parse('2023-01-01'), + Carbon::parse('2023-06-30') + ) + ->perMonth() + ->forecastUntil(Carbon::parse('2023-12-31'), 'moving-average') + ->count(); + + expect($trend)->toBeInstanceOf(Collection::class); + + // Should have 6 historical + 6 forecast points (Jul-Dec) + expect($trend->filter(fn ($value) => $value->isForecast))->toHaveCount(6); + + // All forecast values should be the same (moving average) + $forecasts = $trend->filter(fn ($value) => $value->isForecast)->values(); + $firstForecastValue = $forecasts[0]->aggregate; + + foreach ($forecasts as $forecast) { + expect($forecast->aggregate)->toBe($firstForecastValue); + } +}); + +it('has the forecast flag set correctly on values', function () { + // Set up mock to return test data + $this->baseBuilder->shouldReceive('selectRaw')->andReturnSelf(); + $this->baseBuilder->shouldReceive('whereBetween')->andReturnSelf(); + $this->baseBuilder->shouldReceive('groupBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('orderBy')->andReturnSelf(); + $this->baseBuilder->shouldReceive('get')->andReturn($this->testData); + + // Create trend with forecasting + $trend = Trend::query($this->builder) + ->between( + Carbon::parse('2023-01-01'), + Carbon::parse('2023-06-30') + ) + ->perMonth() + ->forecastPeriods(2, 'linear') + ->count(); + + // Historical values should have isForecast = false + $historicalValues = $trend->filter(fn ($value) => !$value->isForecast); + foreach ($historicalValues as $value) { + expect($value->isForecast)->toBeFalse(); + } + + // Forecast values should have isForecast = true + $forecastValues = $trend->filter(fn ($value) => $value->isForecast); + foreach ($forecastValues as $value) { + expect($value->isForecast)->toBeTrue(); + } +}); + +it('throws an error when forecasting periods without interval', function () { + $trend = Trend::query($this->builder) + ->between( + Carbon::parse('2023-01-01'), + Carbon::parse('2023-06-30') + ); + + expect(fn () => $trend->forecastPeriods(3))->toThrow(Error::class); +}); + +it('throws an error when forecasting periods without end date', function () { + $trend = Trend::query($this->builder) + ->perMonth(); + + expect(fn () => $trend->forecastPeriods(3))->toThrow(Error::class); +}); \ No newline at end of file