diff --git a/app/Console/Commands/FetchOilPrices.php b/app/Console/Commands/FetchOilPrices.php new file mode 100644 index 0000000..7cbe695 --- /dev/null +++ b/app/Console/Commands/FetchOilPrices.php @@ -0,0 +1,37 @@ +fetchFromEia(); + $this->info('Fetched Brent prices from EIA.'); + + return self::SUCCESS; + } catch (BrentPriceFetchException $e) { + $this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...'); + } + + try { + $fetcher->fetchFromFred(); + $this->info('Fetched Brent prices from FRED.'); + + return self::SUCCESS; + } catch (BrentPriceFetchException $e) { + $this->error('Both EIA and FRED failed: '.$e->getMessage()); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/PredictOilPrices.php b/app/Console/Commands/PredictOilPrices.php index f9d1d79..ea74ed2 100644 --- a/app/Console/Commands/PredictOilPrices.php +++ b/app/Console/Commands/PredictOilPrices.php @@ -2,26 +2,37 @@ namespace App\Console\Commands; -use App\Services\OilPriceService; +use App\Services\BrentPricePredictor; use Illuminate\Console\Command; use Throwable; class PredictOilPrices extends Command { - protected $signature = 'oil:predict {--fetch : Fetch latest FRED prices before predicting}'; + protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}'; protected $description = 'Generate a Brent crude oil price direction prediction'; - public function handle(OilPriceService $service): int + public function handle(BrentPricePredictor $predictor): int { try { - if ($this->option('fetch')) { - $this->info('Fetching latest Brent crude prices from FRED...'); - $service->fetchBrentPrices(); + $latest = $predictor->latestPrice(); + + if ($latest?->prediction_generated_at !== null && ! $this->option('force')) { + $message = sprintf( + 'Prediction already generated for %s at %s.', + $latest->date->toDateString(), + $latest->prediction_generated_at->toDateTimeString(), + ); + + if (! $this->confirm($message.' Run again anyway?', default: false)) { + $this->info('Skipped.'); + + return self::SUCCESS; + } } $this->info('Generating prediction...'); - $prediction = $service->generatePrediction(); + $prediction = $predictor->generatePrediction(); if ($prediction === null) { $this->error('Could not generate a prediction — not enough price data.'); diff --git a/app/Models/BrentPrice.php b/app/Models/BrentPrice.php index 46f1e7f..921fac6 100644 --- a/app/Models/BrentPrice.php +++ b/app/Models/BrentPrice.php @@ -6,9 +6,8 @@ use Database\Factories\BrentPriceFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; -#[Fillable(['date', 'price_usd'])] +#[Fillable(['date', 'price_usd', 'prediction_generated_at'])] class BrentPrice extends Model { /** @use HasFactory */ @@ -27,6 +26,7 @@ class BrentPrice extends Model return [ 'date' => 'date', 'price_usd' => 'decimal:2', + 'prediction_generated_at' => 'datetime', ]; } } diff --git a/app/Services/BrentPriceFetcher.php b/app/Services/BrentPriceFetcher.php new file mode 100644 index 0000000..b4bb901 --- /dev/null +++ b/app/Services/BrentPriceFetcher.php @@ -0,0 +1,44 @@ +eia->fetch(); + + if ($rows === null) { + throw new BrentPriceFetchException('EIA fetch returned no data'); + } + + BrentPrice::upsert($rows, ['date'], ['price_usd']); + } + + /** + * Fetch from FRED and persist. Throws on failure. + */ + public function fetchFromFred(): void + { + $rows = $this->fred->fetch(); + + if ($rows === null) { + throw new BrentPriceFetchException('FRED fetch returned no data'); + } + + BrentPrice::upsert($rows, ['date'], ['price_usd']); + } +} diff --git a/app/Services/OilPriceService.php b/app/Services/BrentPricePredictor.php similarity index 62% rename from app/Services/OilPriceService.php rename to app/Services/BrentPricePredictor.php index 446ef97..95d94ad 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/BrentPricePredictor.php @@ -6,70 +6,42 @@ use App\Enums\PredictionSource; use App\Enums\TrendDirection; use App\Models\BrentPrice; use App\Models\PricePrediction; -use App\Services\BrentPriceSources\EiaBrentPriceSource; -use App\Services\BrentPriceSources\FredBrentPriceSource; use App\Services\LlmPrediction\OilPredictionProvider; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Log; -class OilPriceService +final class BrentPricePredictor { - /** - * Decay factor for EWMA. Higher = more weight on recent prices. - */ private const float EWMA_ALPHA = 0.3; - /** - * Minimum % change in EWMA to be considered rising/falling. - */ private const float EWMA_THRESHOLD_PCT = 1.5; - /** - * EWMA confidence is capped lower than LLM — it's a simpler model. - */ private const int EWMA_MAX_CONFIDENCE = 65; - /** - * Minimum price rows needed before EWMA is meaningful. - */ private const int EWMA_MIN_ROWS = 14; public function __construct( private readonly OilPredictionProvider $provider, - private readonly EiaBrentPriceSource $eia, - private readonly FredBrentPriceSource $fred, ) {} /** - * Fetch the last 30 days of Brent crude prices. - * Tries each configured source in order; upserts the first successful result. + * Return the latest BrentPrice row, or null if none exists. */ - public function fetchBrentPrices(): void + public function latestPrice(): ?BrentPrice { - foreach ([$this->eia, $this->fred] as $source) { - $rows = $source->fetch(); - - if ($rows !== null) { - BrentPrice::upsert($rows, ['date'], ['price_usd']); - - return; - } - } - - Log::error('OilPriceService: all Brent price sources failed'); + return BrentPrice::orderBy('date', 'desc')->first(); } /** - * Generate predictions from all available sources and store each one. - * EWMA always runs. LLM provider runs and returns null if not configured. - * Returns the highest-confidence prediction (LLM preferred over EWMA). + * Generate EWMA + LLM predictions, store them, and flag the latest + * brent_prices row as having a prediction generated. */ public function generatePrediction(): ?PricePrediction { $prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get(); if ($prices->count() < self::EWMA_MIN_ROWS) { - Log::warning('OilPriceService: not enough price data to generate prediction', [ + Log::warning('BrentPricePredictor: not enough price data', [ 'rows' => $prices->count(), ]); @@ -88,13 +60,15 @@ class OilPriceService PricePrediction::create($llm->toArray()); } - return $llm ?? $ewma; + $result = $llm ?? $ewma; + + if ($result !== null) { + $prices->first()->forceFill(['prediction_generated_at' => now()])->save(); + } + + return $result; } - /** - * Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable. - * Compares the 3-day EWMA against the 7-day EWMA to detect direction. - */ public function generateEwmaPrediction(Collection $prices): ?PricePrediction { $chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all(); @@ -139,9 +113,7 @@ class OilPriceService } /** - * Compute Exponential Weighted Moving Average for a series of prices. - * - * @param float[] $prices Chronological order (oldest first) + * @param float[] $prices Chronological (oldest first). */ private function computeEwma(array $prices): float { @@ -154,10 +126,6 @@ class OilPriceService return round($ema, 4); } - /** - * Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score. - * 1.5% → ~30, 3% → ~50, 5%+ → 65. - */ private function ewmaConfidence(float $changePct): int { $scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE; diff --git a/app/Services/BrentPriceSources/BrentPriceFetchException.php b/app/Services/BrentPriceSources/BrentPriceFetchException.php new file mode 100644 index 0000000..99a896f --- /dev/null +++ b/app/Services/BrentPriceSources/BrentPriceFetchException.php @@ -0,0 +1,7 @@ +timestamp('prediction_generated_at')->nullable()->after('price_usd'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('brent_prices', function (Blueprint $table) { + $table->dropColumn('prediction_generated_at'); + }); + } +}; diff --git a/tests/Unit/Services/BrentPriceFetcherTest.php b/tests/Unit/Services/BrentPriceFetcherTest.php new file mode 100644 index 0000000..b88e78a --- /dev/null +++ b/tests/Unit/Services/BrentPriceFetcherTest.php @@ -0,0 +1,120 @@ +fetcher = new BrentPriceFetcher( + new EiaBrentPriceSource($apiLogger), + new FredBrentPriceSource($apiLogger), + ); +}); + +it('fetches and stores brent prices from EIA', function (): void { + Http::fake([ + '*eia.gov/*' => Http::response([ + 'response' => [ + 'data' => [ + ['period' => '2026-04-02', 'value' => '73.80'], + ['period' => '2026-04-01', 'value' => '75.10'], + ], + ], + ]), + ]); + + $this->fetcher->fetchFromEia(); + + expect(BrentPrice::count())->toBe(2) + ->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80'); +}); + +it('throws when EIA returns a 500', function (): void { + Http::fake(['*eia.gov/*' => Http::response([], 500)]); + + $this->fetcher->fetchFromEia(); +})->throws(BrentPriceFetchException::class); + +it('throws when EIA returns empty data', function (): void { + Http::fake(['*eia.gov/*' => Http::response(['response' => ['data' => []]])]); + + $this->fetcher->fetchFromEia(); +})->throws(BrentPriceFetchException::class); + +it('filters out EIA missing value markers', function (): void { + Http::fake([ + '*eia.gov/*' => Http::response([ + 'response' => [ + 'data' => [ + ['period' => '2026-04-01', 'value' => '75.10'], + ['period' => '2026-04-02', 'value' => '.'], + ['period' => '2026-04-03', 'value' => '74.20'], + ], + ], + ]), + ]); + + $this->fetcher->fetchFromEia(); + + expect(BrentPrice::count())->toBe(2) + ->and(BrentPrice::find('2026-04-02'))->toBeNull(); +}); + +it('fetches and stores brent prices from FRED', function (): void { + Http::fake([ + '*/fred/series/observations*' => Http::response([ + 'observations' => [ + ['date' => '2026-04-01', 'value' => '75.10'], + ['date' => '2026-04-02', 'value' => '73.80'], + ], + ]), + ]); + + $this->fetcher->fetchFromFred(); + + expect(BrentPrice::count())->toBe(2); +}); + +it('throws when FRED fails', function (): void { + Http::fake(['*/fred/series/observations*' => Http::response([], 500)]); + + $this->fetcher->fetchFromFred(); +})->throws(BrentPriceFetchException::class); + +it('filters out FRED missing value markers', function (): void { + Http::fake([ + '*/fred/series/observations*' => Http::response([ + 'observations' => [ + ['date' => '2026-04-01', 'value' => '75.10'], + ['date' => '2026-04-02', 'value' => '.'], + ], + ]), + ]); + + $this->fetcher->fetchFromFred(); + + expect(BrentPrice::count())->toBe(1); +}); + +it('upserts existing rows on refetch', function (): void { + Http::fake([ + '*eia.gov/*' => Http::sequence() + ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '74.00']]]]) + ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '75.50']]]]), + ]); + + $this->fetcher->fetchFromEia(); + $this->fetcher->fetchFromEia(); + + expect(BrentPrice::count())->toBe(1) + ->and(BrentPrice::find('2026-04-01')->price_usd)->toBe('75.50'); +}); diff --git a/tests/Unit/Services/BrentPricePredictorTest.php b/tests/Unit/Services/BrentPricePredictorTest.php new file mode 100644 index 0000000..9025427 --- /dev/null +++ b/tests/Unit/Services/BrentPricePredictorTest.php @@ -0,0 +1,144 @@ +provider = Mockery::mock(OilPredictionProvider::class); + $this->predictor = new BrentPricePredictor($this->provider); +}); + +it('detects a rising trend when 3-day EWMA exceeds 7-day EWMA by threshold', function (): void { + $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(14 - $i)->toDateString(), + 'price_usd' => 70.0 + ($i * 2.0), + ])); + + $prediction = $this->predictor->generateEwmaPrediction($prices); + + expect($prediction->direction)->toBe(TrendDirection::Rising) + ->and($prediction->source)->toBe(PredictionSource::Ewma) + ->and($prediction->confidence)->toBeGreaterThan(0) + ->and($prediction->confidence)->toBeLessThanOrEqual(65); +}); + +it('detects a falling trend when 3-day EWMA falls below 7-day EWMA by threshold', function (): void { + $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(14 - $i)->toDateString(), + 'price_usd' => 85.0 - ($i * 2.0), + ])); + + $prediction = $this->predictor->generateEwmaPrediction($prices); + + expect($prediction->direction)->toBe(TrendDirection::Falling); +}); + +it('returns flat when price movement is within threshold', function (): void { + $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(14 - $i)->toDateString(), + 'price_usd' => 75.0 + (($i % 2 === 0) ? 0.1 : -0.1), + ])); + + $prediction = $this->predictor->generateEwmaPrediction($prices); + + expect($prediction->direction)->toBe(TrendDirection::Flat) + ->and($prediction->confidence)->toBe(50); +}); + +it('returns null when fewer than 14 prices are available for EWMA', function (): void { + $prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(10 - $i)->toDateString(), + 'price_usd' => 75.0, + ])); + + expect($this->predictor->generateEwmaPrediction($prices))->toBeNull(); +}); + +it('stores both EWMA and LLM predictions when provider succeeds', function (): void { + seedPrices(20); + + $this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([ + 'predicted_for' => now()->toDateString(), + 'source' => PredictionSource::LlmWithContext, + 'direction' => TrendDirection::Rising, + 'confidence' => 70, + 'reasoning' => 'Trend is up.', + 'generated_at' => now(), + ])); + + $prediction = $this->predictor->generatePrediction(); + + expect($prediction->source)->toBe(PredictionSource::LlmWithContext) + ->and(PricePrediction::count())->toBe(2); +}); + +it('falls back to EWMA when provider returns null', function (): void { + seedPrices(20, slope: 0.8); + + $this->provider->shouldReceive('predict')->once()->andReturn(null); + + $prediction = $this->predictor->generatePrediction(); + + expect($prediction->source)->toBe(PredictionSource::Ewma) + ->and(PricePrediction::count())->toBe(1); +}); + +it('returns null when there is insufficient price data', function (): void { + BrentPrice::insert([ + ['date' => now()->subDays(2)->toDateString(), 'price_usd' => 75.0], + ['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0], + ]); + + $this->provider->shouldNotReceive('predict'); + + expect($this->predictor->generatePrediction())->toBeNull() + ->and(PricePrediction::count())->toBe(0); +}); + +it('flags latest brent price as prediction generated on success', function (): void { + seedPrices(20); + + $this->provider->shouldReceive('predict')->once()->andReturn(null); + + $this->predictor->generatePrediction(); + + $latest = BrentPrice::orderBy('date', 'desc')->first(); + + expect($latest->prediction_generated_at)->not->toBeNull(); +}); + +it('does not flag when prediction cannot be generated', function (): void { + BrentPrice::insert([ + ['date' => now()->subDay()->toDateString(), 'price_usd' => 75.0], + ]); + + $this->provider->shouldNotReceive('predict'); + + $this->predictor->generatePrediction(); + + expect(BrentPrice::first()->prediction_generated_at)->toBeNull(); +}); + +it('returns the latest price row', function (): void { + seedPrices(3); + + expect($this->predictor->latestPrice())->not->toBeNull() + ->and($this->predictor->latestPrice()->date->toDateString())->toBe(now()->toDateString()); +}); + +function seedPrices(int $count, float $slope = 1.0): void +{ + BrentPrice::insert( + collect(range(1, $count))->map(fn (int $i) => [ + 'date' => now()->subDays($count - $i)->toDateString(), + 'price_usd' => 75.0 + ($i * $slope), + ])->all() + ); +} diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php deleted file mode 100644 index f5a88e4..0000000 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ /dev/null @@ -1,250 +0,0 @@ -provider = Mockery::mock(OilPredictionProvider::class); - $apiLogger = new ApiLogger; - $this->service = new OilPriceService( - $this->provider, - new EiaBrentPriceSource($apiLogger), - new FredBrentPriceSource($apiLogger), - ); -}); - -// --- fetchBrentPrices --- - -it('fetches and stores brent prices from EIA when EIA succeeds', function (): void { - Http::fake([ - '*eia.gov/*' => Http::response([ - 'response' => [ - 'data' => [ - ['period' => '2026-04-02', 'value' => '73.80'], - ['period' => '2026-04-01', 'value' => '75.10'], - ['period' => '2026-03-31', 'value' => '74.50'], - ], - ], - ]), - '*/fred/*' => Http::response([], 500), - ]); - - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(3) - ->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80'); - Http::assertNotSent(fn ($request) => str_contains($request->url(), 'stlouisfed')); -}); - -it('falls back to FRED when EIA returns a 500', function (): void { - Http::fake([ - '*eia.gov/*' => Http::response([], 500), - '*/fred/series/observations*' => Http::response([ - 'observations' => [ - ['date' => '2026-04-01', 'value' => '75.10'], - ['date' => '2026-04-02', 'value' => '73.80'], - ], - ]), - ]); - - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(2); -}); - -it('falls back to FRED when EIA returns empty data', function (): void { - Http::fake([ - '*eia.gov/*' => Http::response(['response' => ['data' => []]]), - '*/fred/series/observations*' => Http::response([ - 'observations' => [ - ['date' => '2026-04-01', 'value' => '75.10'], - ], - ]), - ]); - - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(1); -}); - -it('stores no rows and logs error when both EIA and FRED fail', function (): void { - Http::fake([ - '*eia.gov/*' => Http::response([], 500), - '*/fred/series/observations*' => Http::response([], 500), - ]); - - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(0); -}); - -it('filters out EIA missing value markers', function (): void { - Http::fake([ - '*eia.gov/*' => Http::response([ - 'response' => [ - 'data' => [ - ['period' => '2026-04-01', 'value' => '75.10'], - ['period' => '2026-04-02', 'value' => '.'], - ['period' => '2026-04-03', 'value' => '74.20'], - ], - ], - ]), - ]); - - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(2) - ->and(BrentPrice::find('2026-04-02'))->toBeNull(); -}); - -it('upserts existing brent price rows on refetch via EIA', function (): void { - Http::fake([ - '*eia.gov/*' => Http::sequence() - ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '74.00']]]]) - ->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '75.50']]]]), - ]); - - $this->service->fetchBrentPrices(); - $this->service->fetchBrentPrices(); - - expect(BrentPrice::count())->toBe(1) - ->and(BrentPrice::find('2026-04-01')->price_usd)->toBe('75.50'); -}); - -// --- generateEwmaPrediction --- - -it('detects a rising trend when 3-day EWMA exceeds 7-day EWMA by threshold', function (): void { - $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ - 'date' => now()->subDays(14 - $i)->toDateString(), - 'price_usd' => 70.0 + ($i * 2.0), - ])); - - $prediction = $this->service->generateEwmaPrediction($prices); - - expect($prediction->direction)->toBe(TrendDirection::Rising) - ->and($prediction->source)->toBe(PredictionSource::Ewma) - ->and($prediction->confidence)->toBeGreaterThan(0) - ->and($prediction->confidence)->toBeLessThanOrEqual(65); -}); - -it('detects a falling trend when 3-day EWMA falls below 7-day EWMA by threshold', function (): void { - $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ - 'date' => now()->subDays(14 - $i)->toDateString(), - 'price_usd' => 85.0 - ($i * 2.0), - ])); - - $prediction = $this->service->generateEwmaPrediction($prices); - - expect($prediction->direction)->toBe(TrendDirection::Falling) - ->and($prediction->source)->toBe(PredictionSource::Ewma); -}); - -it('returns flat when price movement is within threshold', function (): void { - $prices = collect(range(1, 14))->map(fn (int $i) => new BrentPrice([ - 'date' => now()->subDays(14 - $i)->toDateString(), - 'price_usd' => 75.0 + (($i % 2 === 0) ? 0.1 : -0.1), - ])); - - $prediction = $this->service->generateEwmaPrediction($prices); - - expect($prediction->direction)->toBe(TrendDirection::Flat) - ->and($prediction->confidence)->toBe(50); -}); - -it('returns null when fewer than 14 prices are available for EWMA', function (): void { - $prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([ - 'date' => now()->subDays(10 - $i)->toDateString(), - 'price_usd' => 75.0, - ])); - - expect($this->service->generateEwmaPrediction($prices))->toBeNull(); -}); - -// --- generatePrediction (orchestrator) --- - -it('stores both EWMA and LLM predictions when provider succeeds', function (): void { - BrentPrice::insert( - collect(range(1, 20))->map(fn (int $i) => [ - 'date' => now()->subDays(20 - $i)->toDateString(), - 'price_usd' => 75.0 + $i, - ])->all() - ); - - $this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([ - 'predicted_for' => now()->toDateString(), - 'source' => PredictionSource::LlmWithContext, - 'direction' => TrendDirection::Rising, - 'confidence' => 70, - 'reasoning' => 'Trend is up.', - 'generated_at' => now(), - ])); - - $prediction = $this->service->generatePrediction(); - - expect($prediction->source)->toBe(PredictionSource::LlmWithContext) - ->and(PricePrediction::count())->toBe(2); -}); - -it('returns LLM prediction when provider succeeds', function (): void { - BrentPrice::insert( - collect(range(1, 20))->map(fn (int $i) => [ - 'date' => now()->subDays(20 - $i)->toDateString(), - 'price_usd' => 75.0 + $i, - ])->all() - ); - - $llmPrediction = new PricePrediction([ - 'predicted_for' => now()->toDateString(), - 'source' => PredictionSource::Llm, - 'direction' => TrendDirection::Rising, - 'confidence' => 65, - 'reasoning' => 'Rising trend.', - 'generated_at' => now(), - ]); - - $this->provider->shouldReceive('predict')->once()->andReturn($llmPrediction); - - $prediction = $this->service->generatePrediction(); - - expect($prediction->source)->toBe(PredictionSource::Llm); -}); - -it('falls back to EWMA when provider returns null', function (): void { - BrentPrice::insert( - collect(range(1, 20))->map(fn (int $i) => [ - 'date' => now()->subDays(20 - $i)->toDateString(), - 'price_usd' => 75.0 + ($i * 0.8), - ])->all() - ); - - $this->provider->shouldReceive('predict')->once()->andReturn(null); - - $prediction = $this->service->generatePrediction(); - - expect($prediction->source)->toBe(PredictionSource::Ewma) - ->and(PricePrediction::count())->toBe(1); -}); - -it('returns null when there is insufficient price data', function (): void { - BrentPrice::insert([ - ['date' => now()->subDays(2)->toDateString(), 'price_usd' => 75.0], - ['date' => now()->subDay()->toDateString(), 'price_usd' => 76.0], - ]); - - $this->provider->shouldNotReceive('predict'); - - expect($this->service->generatePrediction())->toBeNull() - ->and(PricePrediction::count())->toBe(0); -});