Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/fees/trend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
57 changes: 57 additions & 0 deletions test/feeTrend.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading