insert([ 'date' => $start->copy()->addWeeks($i)->toDateString(), 'ulsp_pence' => $price, 'ulsd_pence' => $price + 800, 'ulsp_duty_pence' => 5295, 'ulsd_duty_pence' => 5295, 'ulsp_vat_pct' => 20, 'ulsd_vat_pct' => 20, ]); } } it('train + predict produces a non-zero, finite magnitude', function () { seedRidgeFixture(30); $loader = new WeeklyPumpPriceLoader; $model = new RidgeRegressionModel( spec: new FeatureSpec('ridge-test', [ new DeltaUlspLag($loader, lag: 0), new DeltaUlspLag($loader, lag: 1), new UlspMinusMa8($loader), ]), loader: $loader, lambda: 1.0, ); $training = collect(range(0, 20))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); $model->train($training); $prediction = $model->predict(Carbon::parse('2024-06-03')); expect(is_finite($prediction->magnitudePence))->toBeTrue() ->and($prediction->direction)->toBeIn(['rising', 'falling', 'flat']); }); it('coefficients() returns a structured payload after training', function () { seedRidgeFixture(30); $loader = new WeeklyPumpPriceLoader; $features = [ new DeltaUlspLag($loader, lag: 0), new DeltaUlspLag($loader, lag: 1), ]; $model = new RidgeRegressionModel( spec: new FeatureSpec('ridge-test', $features), loader: $loader, lambda: 1.0, ); $training = collect(range(0, 20))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); $model->train($training); $c = $model->coefficients(); expect($c)->toHaveKey('intercept') ->and($c)->toHaveKey('lambda') ->and($c['lambda'])->toBe(1.0) ->and($c['features'])->toHaveKey('delta_ulsp_lag_0') ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('beta_standardised') ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('mean') ->and($c['features']['delta_ulsp_lag_0'])->toHaveKey('std_dev'); }); it('throws when predict is called before train', function () { $loader = new WeeklyPumpPriceLoader; $model = new RidgeRegressionModel( spec: new FeatureSpec('ridge-test', [new DeltaUlspLag($loader, lag: 0)]), loader: $loader, lambda: 1.0, ); $model->predict(Carbon::parse('2024-06-03')); })->throws(RuntimeException::class); it('throws when training data is too thin to fit the model', function () { seedRidgeFixture(8); // not enough training rows after losing first 8 weeks to lags $loader = new WeeklyPumpPriceLoader; $model = new RidgeRegressionModel( spec: new FeatureSpec('ridge-test', [ new DeltaUlspLag($loader, lag: 3), new UlspMinusMa8($loader), ]), loader: $loader, lambda: 1.0, ); $training = collect(range(0, 4))->map(fn (int $i): Carbon => Carbon::parse('2024-01-01')->addWeeks($i))->all(); $model->train($training); })->throws(RuntimeException::class); it('beats the naive zero-change baseline on the synthetic fixture', function () { seedRidgeFixture(30); $loader = new WeeklyPumpPriceLoader; $features = [ new DeltaUlspLag($loader, lag: 0), new UlspMinusMa8($loader), ]; $ridge = new RidgeRegressionModel( spec: new FeatureSpec('ridge-test', $features), loader: $loader, lambda: 1.0, ); $naive = new NaiveZeroChangeModel; $runner = new BacktestRunner; $ridgeResult = $runner->run( $ridge, trainStart: Carbon::parse('2024-01-01'), trainEnd: Carbon::parse('2024-04-29'), evalStart: Carbon::parse('2024-05-06'), evalEnd: Carbon::parse('2024-07-22'), ); $naiveResult = $runner->run( $naive, trainStart: Carbon::parse('2024-01-01'), trainEnd: Carbon::parse('2024-04-29'), evalStart: Carbon::parse('2024-05-06'), evalEnd: Carbon::parse('2024-07-22'), ); expect((float) $ridgeResult->mae_pence) ->toBeLessThan((float) $naiveResult->mae_pence); });