diff --git a/src/fees/trend.ts b/src/fees/trend.ts index 5013149..544bbb7 100644 --- a/src/fees/trend.ts +++ b/src/fees/trend.ts @@ -103,3 +103,39 @@ export class FeeTrendAnalyzer { this.buffer.evictOldestWhile((sample) => sample.capturedAt < cutoff); } } + +/** + * Compute a Simple Moving Average (SMA) series for an array of numbers. + * + * The first `windowSize - 1` entries are padded with `NaN` because a full + * window is not yet available. All calculations are pure: no side effects, + * no input mutation. + * + * @param samples - Raw numeric series (e.g. fee samples over time). + * @param windowSize - Number of consecutive elements to average. Must be ≥ 1. + * @returns An array of the same length where each element `i` is the SMA of + * `samples.slice(i - windowSize + 1, i + 1)` when `i >= windowSize - 1`, + * otherwise `NaN`. + * @throws {RangeError} When `windowSize` is less than 1. + */ +export function computeMovingAverage(samples: number[], windowSize: number): number[] { + if (windowSize < 1) { + throw new RangeError(`windowSize must be at least 1, got ${windowSize}`); + } + if (samples.length === 0) return []; + + const result: number[] = new Array(samples.length).fill(NaN); + let windowSum = 0; + + for (let i = 0; i < samples.length; i++) { + windowSum += samples[i]!; + if (i >= windowSize) { + windowSum -= samples[i - windowSize]!; + } + if (i >= windowSize - 1) { + result[i] = windowSum / windowSize; + } + } + + return result; +} diff --git a/test/feeTrend.test.ts b/test/feeTrend.test.ts new file mode 100644 index 0000000..68ef99d --- /dev/null +++ b/test/feeTrend.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { computeMovingAverage } from "../src/fees/trend.js"; + +describe("computeMovingAverage", () => { + it("returns NaN-padded SMA for a valid window", () => { + const samples = [1, 2, 3, 4, 5]; + const result = computeMovingAverage(samples, 3); + expect(result[0]).toBeNaN(); + expect(result[1]).toBeNaN(); + expect(result[2]).toBeCloseTo(2, 10); + expect(result[3]).toBeCloseTo(3, 10); + expect(result[4]).toBeCloseTo(4, 10); + }); + + it("returns the original value when windowSize is 1", () => { + const samples = [10, 20, 30]; + const result = computeMovingAverage(samples, 1); + expect(result).toEqual([10, 20, 30]); + }); + + it("returns all NaN when windowSize exceeds sample length", () => { + const samples = [1, 2]; + const result = computeMovingAverage(samples, 5); + expect(result[0]).toBeNaN(); + expect(result[1]).toBeNaN(); + }); + + it("returns an empty array for empty input", () => { + expect(computeMovingAverage([], 3)).toEqual([]); + }); + + it("throws RangeError when windowSize is 0", () => { + expect(() => computeMovingAverage([1, 2, 3], 0)).toThrow(RangeError); + }); + + it("throws RangeError when windowSize is negative", () => { + expect(() => computeMovingAverage([1, 2, 3], -1)).toThrow(RangeError); + }); + + it("matches hand-calculated SMA for a longer series", () => { + const samples = [10, 11, 12, 13, 14, 15]; + const result = computeMovingAverage(samples, 4); + expect(result[0]).toBeNaN(); + expect(result[1]).toBeNaN(); + expect(result[2]).toBeNaN(); + expect(result[3]).toBeCloseTo(11.5, 10); // (10+11+12+13)/4 + expect(result[4]).toBeCloseTo(12.5, 10); // (11+12+13+14)/4 + expect(result[5]).toBeCloseTo(13.5, 10); // (12+13+14+15)/4 + }); + + it("does not mutate the input array", () => { + const samples = [1, 2, 3, 4, 5]; + const snapshot = [...samples]; + computeMovingAverage(samples, 3); + expect(samples).toEqual(snapshot); + }); +});