ts-arima-forecast
Version:
TypeScript library for ARIMA, SARIMA (soon), and SARIMAX (soon) forecasting
718 lines (643 loc) • 17.3 kB
text/typescript
import { Stepwise, RollingWindow, Adaptive } from '../../utils/strategies';
import { ARIMA } from '../../models/arima';
describe('Forecasting Strategies', () => {
let baseModel: ARIMA;
let trainData: number[];
let testData: number[];
const btcData = [
67766.85,
67765.63,
68809.9,
70537.84,
71108,
70799.06,
69355.6,
69310.46,
69648.14,
69540,
67314.24,
68263.99,
66773.01,
66043.99,
66228.25,
66676.87,
66504.33,
65175.32,
64974.37,
64869.99,
64143.56,
64262.01,
63210.01,
60293.3,
61806.01,
60864.99,
61706.47,
60427.84,
60986.68,
62772.01,
62899.99,
62135.47,
60208.58,
57050.01,
56628.79,
58230.13,
55857.81,
56714.62,
58050,
57725.85,
57339.89,
57889.1,
59204.02,
60797.91,
64724.14,
65043.99,
64087.99,
63987.92,
66660,
67139.96,
68165.34,
67532.01,
65936.01,
65376,
65799.95,
67907.99,
67896.5,
68249.88,
66784.69,
66188,
64628,
65354.02,
61498.33,
60697.99,
58161,
54018.81,
56022.01,
55134.16,
61685.99,
60837.99,
60923.51,
58712.59,
59346.64,
60587.15,
58683.39,
57541.06,
58874.6,
59491.99,
58427.35,
59438.5,
59013.8,
61156.03,
60375.84,
64037.24,
64157.01,
64220,
62834,
59415,
59034.9,
59359.01,
59123.99,
58973.99,
57301.86,
59132.13,
57487.73,
57970.9,
56180,
53962.97,
54160.86,
54869.95,
57042,
57635.99,
57338,
58132.32,
60498,
59993.03,
59132,
58213.99,
60313.99,
61759.99,
62947.99,
63201.05,
63348.96,
63578.76,
63339.99,
64262.7,
63152.01,
65173.99,
65769.95,
65858,
65602.01,
63327.59,
60805.78,
60649.28,
60752.71,
62086,
62058,
62819.91,
62224,
62160.49,
60636.02,
60326.39,
62540,
63206.22,
62870.02,
66083.99,
67074.14,
67620.01,
67421.78,
68428,
68378,
69031.99,
67377.5,
67426,
66668.65,
68198.28,
66698.33,
67092.76,
68021.7,
69962.21,
72736.42,
72344.74,
70292.01,
69496.01,
69374.74,
68775.99,
67850.01,
69372.01,
75571.99,
75857.89,
76509.78,
76677.46,
80370.01,
88647.99,
87952.01,
90375.2,
87325.59,
91032.07,
90586.92,
89855.99,
90464.08,
92310.79,
94286.56,
98317.12,
98892,
97672.4,
97900.04,
93010.01,
91965.16,
95863.11,
95643.98,
97460,
96407.99,
97185.18,
95840.62,
95849.69,
98587.32,
96945.63,
99740.84,
99831.99,
101109.59,
97276.47,
96593,
101125,
100004.29,
101424.25,
101420,
104463.99,
106058.66,
106133.74,
100204.01,
97461.86,
97805.44,
97291.99,
95186.27,
94881.47,
98663.58,
99429.6,
95791.6,
94299.03,
95300,
93738.2,
92792.05,
93576,
94591.79,
96984.79,
98174.18,
98220.5,
98363.61,
102235.6,
96954.61,
95060.61,
92552.49,
94726.11,
94599.99,
94545.06,
94536.1,
96560.86,
100497.35,
99987.3,
104077.48,
104556.23,
101331.57,
102260.01,
106143.82,
103706.66,
103910.34,
104870.5,
104746.85,
102620,
102082.83,
101335.52,
103733.24,
104722.94,
102429.56,
100635.65,
97700.59,
101328.52,
97763.13,
96612.43,
96554.35,
96506.8,
96444.74,
96462.75,
97430.82,
95778.2,
97869.99,
96608.14,
97500.48,
97569.66,
96118.12,
95780,
95671.74,
96644.37,
98305,
96181.98,
96551.01,
96258,
91552.88,
88680.4,
84250.09,
84708.58,
84349.94,
86064.53,
94270,
86220.61,
87281.98,
90606.01,
89931.89,
86801.75,
86222.45,
80734.37,
78595.86,
82932.99,
83680.12,
81115.78,
83983.2,
84338.44,
82574.53,
84010.03,
82715.03,
86845.94,
84223.39,
84088.79,
83840.59,
86082.5,
87498.16,
87392.87,
86909.17,
87232.01,
84424.38,
82648.54,
82389.99,
82550.01,
85158.34,
82516.29,
83213.09,
83889.87,
83537.99,
78430,
79163.24,
76322.42,
82615.22,
79607.3,
83423.84,
85276.9,
83760,
84591.58,
83643.99,
84030.38,
84947.91,
84474.69,
85077.01,
85179.24,
87516.23,
93442.99,
93691.08,
93980.47,
94638.68,
94628,
93749.3,
95011.18,
94256.82,
94172,
96489.91,
96887.14,
95856.42,
94277.62,
94733.68,
96834.02,
97030.5,
103261.6,
102971.99,
104809.53,
104118,
102791.32,
104103.72,
103507.82,
103763.71,
103463.9,
103126.65,
106454.26,
105573.74,
106849.99,
109643.99,
111696.21,
107318.3,
107761.91,
109004.19,
109434.79,
108938.17,
107781.78,
105589.75,
103985.48,
104644
]
beforeEach(() => {
// Generate consistent test data
trainData = btcData.slice(btcData.length * 0.8);
testData = btcData.slice(0, btcData.length * 0.2);
// Create and fit base model
baseModel = new ARIMA({ p: 1, d: 1, q: 1 });
baseModel.fit(trainData);
});
describe('Stepwise Strategy', () => {
let stepwise: Stepwise;
beforeEach(() => {
stepwise = new Stepwise(baseModel, trainData, {
refitModel: true,
verbose: false
});
});
test('should initialize correctly', () => {
expect(stepwise).toBeInstanceOf(Stepwise);
expect(stepwise.getCurrentData()).toEqual(trainData);
expect(stepwise.getCurrentModel()).toBeInstanceOf(ARIMA);
});
test('should forecast multiple steps ahead', () => {
const result = stepwise.forecast(5);
expect(result.forecasts).toHaveLength(5);
expect(result.confidenceIntervals.lower).toHaveLength(5);
expect(result.confidenceIntervals.upper).toHaveLength(5);
// Check that all values are finite numbers
result.forecasts.forEach(forecast => {
expect(Number.isFinite(forecast)).toBe(true);
});
result.confidenceIntervals.lower.forEach(lower => {
expect(Number.isFinite(lower)).toBe(true);
});
result.confidenceIntervals.upper.forEach(upper => {
expect(Number.isFinite(upper)).toBe(true);
});
});
test('should process real-time data batch', () => {
const result = stepwise.forecastWithRealTimeData(testData);
expect(result.forecasts).toHaveLength(testData.length);
expect(result.errors).toHaveLength(testData.length);
expect(result.actualValues).toEqual(testData);
expect(result.updatedModels).toHaveLength(testData.length);
// Check that all errors are non-negative
result.errors!.forEach(error => {
expect(error).toBeGreaterThanOrEqual(0);
});
});
test('should add single observation and forecast', () => {
const newObservation = 100.5;
const result = stepwise.addObservationAndForecast(newObservation);
expect(result.forecast).toBeDefined();
expect(Number.isFinite(result.forecast)).toBe(true);
expect(Number.isFinite(result.lowerBound)).toBe(true);
expect(Number.isFinite(result.upperBound)).toBe(true);
expect(result.error).toBeUndefined(); // First call should have no error
// Second call should have error
const result2 = stepwise.addObservationAndForecast(101.2);
expect(result2.error).toBeDefined();
expect(result2.error).toBeGreaterThanOrEqual(0);
});
test('should update internal state correctly', () => {
const initialLength = stepwise.getCurrentData().length;
const newObservation = 100.5;
stepwise.addObservationAndForecast(newObservation);
expect(stepwise.getCurrentData()).toHaveLength(initialLength + 1);
expect(stepwise.getCurrentData()[initialLength]).toBe(newObservation);
});
test('should reset correctly', () => {
const newData = [1, 2, 3, 4, 5];
stepwise.reset(newData);
expect(stepwise.getCurrentData()).toEqual(newData);
});
test('should work without model refitting', () => {
const stepwiseNoRefit = new Stepwise(baseModel, trainData, {
refitModel: false,
verbose: false
});
const result = stepwiseNoRefit.forecastWithRealTimeData(testData.slice(0, 3));
expect(result.updatedModels).toHaveLength(0); // No models updated when refitting is disabled
});
});
describe('RollingWindow Strategy', () => {
let rollingWindow: RollingWindow;
const windowSize = 20;
beforeEach(() => {
rollingWindow = new RollingWindow(baseModel, trainData, {
windowSize,
verbose: false
});
});
test('should initialize correctly', () => {
expect(rollingWindow).toBeInstanceOf(RollingWindow);
expect(rollingWindow.getWindowSize()).toBe(windowSize);
expect(rollingWindow.getCurrentData()).toEqual(trainData);
});
test('should forecast multiple steps ahead', () => {
const result = rollingWindow.forecast(5);
expect(result.forecasts).toHaveLength(5);
expect(result.confidenceIntervals.lower).toHaveLength(5);
expect(result.confidenceIntervals.upper).toHaveLength(5);
});
test('should process real-time data batch', () => {
const result = rollingWindow.forecastWithRealTimeData(testData);
console.log(result);
expect(result.forecasts).toHaveLength(testData.length);
expect(result.errors).toHaveLength(testData.length);
expect(result.actualValues).toEqual(testData);
// Check that all errors are non-negative
result.errors!.forEach(error => {
expect(error).toBeGreaterThanOrEqual(0);
});
});
test('should maintain rolling window size', () => {
// Add more observations than window size
const manyObservations = Array.from({ length: windowSize + 10 }, (_, i) => 100 + i);
for (const obs of manyObservations) {
rollingWindow.addObservationAndForecast(obs);
}
// Data should not exceed initial length + window size
expect(rollingWindow.getCurrentData().length).toBeLessThanOrEqual(trainData.length + windowSize + 10);
});
test('should add single observation and forecast', () => {
const newObservation = 100.5;
const result = rollingWindow.addObservationAndForecast(newObservation);
expect(result.forecast).toBeDefined();
expect(Number.isFinite(result.forecast)).toBe(true);
expect(Number.isFinite(result.lowerBound)).toBe(true);
expect(Number.isFinite(result.upperBound)).toBe(true);
expect(result.error).toBeUndefined(); // First call should have no error
// Second call should have error
const result2 = rollingWindow.addObservationAndForecast(101.2);
expect(result2.error).toBeDefined();
expect(result2.error).toBeGreaterThanOrEqual(0);
});
test('should allow window size adjustment', () => {
const newWindowSize = 15;
rollingWindow.setWindowSize(newWindowSize);
expect(rollingWindow.getWindowSize()).toBe(newWindowSize);
});
test('should reset correctly', () => {
const newData = [1, 2, 3, 4, 5];
rollingWindow.reset(newData);
expect(rollingWindow.getCurrentData()).toEqual(newData);
});
test('should handle small window sizes', () => {
const smallWindow = new RollingWindow(baseModel, trainData, {
windowSize: 5,
verbose: false
});
const result = smallWindow.forecast(3);
expect(result.forecasts).toHaveLength(3);
});
});
describe('Adaptive Strategy', () => {
let adaptive: Adaptive;
beforeEach(() => {
adaptive = new Adaptive(baseModel, trainData, {
windowSize: 20,
adaptationThreshold: 2.0,
maxErrorWindowSize: 5,
verbose: false
});
});
test('should initialize correctly', () => {
expect(adaptive).toBeInstanceOf(Adaptive);
expect(adaptive.getCurrentStrategy()).toBe('stepwise'); // Should start with stepwise
});
test('should forecast multiple steps ahead', () => {
const result = adaptive.forecast(5);
expect(result.forecasts).toHaveLength(5);
expect(result.confidenceIntervals.lower).toHaveLength(5);
expect(result.confidenceIntervals.upper).toHaveLength(5);
});
test('should process real-time data batch', () => {
const result = adaptive.forecastWithRealTimeData(testData);
expect(result.forecasts).toHaveLength(testData.length);
expect(result.errors).toHaveLength(testData.length);
expect(result.actualValues).toEqual(testData);
});
test('should add single observation and forecast', () => {
const newObservation = 100.5;
const result = adaptive.addObservationAndForecast(newObservation);
expect(result.forecast).toBeDefined();
expect(Number.isFinite(result.forecast)).toBe(true);
expect(Number.isFinite(result.lowerBound)).toBe(true);
expect(Number.isFinite(result.upperBound)).toBe(true);
expect(result.error).toBeUndefined(); // First call should have no error
});
test('should switch strategies based on performance', () => {
// Create data that should trigger strategy switching
const highErrorData = Array.from({ length: 10 }, () => Math.random() * 1000);
const initialStrategy = adaptive.getCurrentStrategy();
adaptive.forecastWithRealTimeData(highErrorData);
// Strategy might have switched due to high errors
const finalStrategy = adaptive.getCurrentStrategy();
expect(['stepwise', 'rolling']).toContain(finalStrategy);
});
test('should reset correctly', () => {
const newData = [1, 2, 3, 4, 5];
adaptive.reset(newData);
expect(adaptive.getCurrentStrategy()).toBe('stepwise'); // Should reset to stepwise
});
test('should handle strategy switching with verbose logging', () => {
const verboseAdaptive = new Adaptive(baseModel, trainData, {
windowSize: 20,
adaptationThreshold: 1.0, // Low threshold to trigger switching
maxErrorWindowSize: 3,
verbose: true
});
// Add observations that might trigger switching
for (let i = 0; i < 5; i++) {
verboseAdaptive.addObservationAndForecast(Math.random() * 100);
}
expect(['stepwise', 'rolling']).toContain(verboseAdaptive.getCurrentStrategy());
});
});
describe('Strategy Comparison', () => {
test('should compare all strategies on same data', () => {
const strategies = {
stepwise: new Stepwise(baseModel, trainData, { refitModel: true }),
rollingWindow: new RollingWindow(baseModel, trainData, { windowSize: 20 }),
adaptive: new Adaptive(baseModel, trainData, { windowSize: 20 })
};
const results: { [key: string]: number } = {};
for (const [name, strategy] of Object.entries(strategies)) {
const result = strategy.forecastWithRealTimeData(testData);
const avgError = result.errors!.reduce((a, b) => a + b, 0) / result.errors!.length;
results[name] = avgError;
expect(avgError).toBeGreaterThanOrEqual(0);
expect(result.forecasts).toHaveLength(testData.length);
}
// All strategies should produce results
expect(Object.keys(results)).toHaveLength(3);
expect(results.stepwise).toBeDefined();
expect(results.rollingWindow).toBeDefined();
expect(results.adaptive).toBeDefined();
});
});
describe('Edge Cases', () => {
test('should handle empty test data', () => {
const stepwise = new Stepwise(baseModel, trainData);
const result = stepwise.forecastWithRealTimeData([]);
expect(result.forecasts).toHaveLength(0);
expect(result.errors).toHaveLength(0);
expect(result.actualValues).toHaveLength(0);
});
test('should handle single observation', () => {
const rollingWindow = new RollingWindow(baseModel, trainData);
const result = rollingWindow.forecastWithRealTimeData([100.5]);
expect(result.forecasts).toHaveLength(1);
expect(result.errors).toHaveLength(1);
});
test('should handle very small training data', () => {
const smallData = [1, 2, 3];
const smallModel = new ARIMA({ p: 1, d: 0, q: 1 });
smallModel.fit(smallData);
const stepwise = new Stepwise(smallModel, smallData);
const result = stepwise.forecast(2);
expect(result.forecasts).toHaveLength(2);
});
test('should handle large forecast horizons', () => {
const stepwise = new Stepwise(baseModel, trainData);
const result = stepwise.forecast(50);
expect(result.forecasts).toHaveLength(50);
expect(result.confidenceIntervals.lower).toHaveLength(50);
expect(result.confidenceIntervals.upper).toHaveLength(50);
});
});
});