Skip to content

Forecast extras: seasonality, changepoints, Monte Carlo & goal-seek

Beyond the forecast() plugin, the @michi-vz/insights/forecast sub-path ships a set of pure, deterministic helpers that work on a plain number[] - no chart required. Each is a named, textbook method; for the story and demos, see the Insights guide.

Import

ts
import {
  decompose, detectPeriod,          // seasonality
  detectChangepoints,               // regime shifts
  monteCarloForecast,               // simulation
  requiredGrowth, requiredRunRate, pacingToGoal, // goal-seek
} from "@michi-vz/insights/forecast";

Seasonality: decompose / detectPeriod

Classical additive decomposition splits a series into a smooth trend, a repeating seasonal component, and the leftover residual (values = trend + seasonal + residual). detectPeriod finds the cycle length on its own via autocorrelation.

decompose · trend + season + noise
ts
const period = detectPeriod(values);            // e.g. 4 (quarterly), or 1 if not clearly periodic
const { trend, seasonal, residual } = decompose(values, period);
FunctionSignatureReturns
detectPeriod(values: number[], maxPeriod?: number) => numberDominant period (lag with the highest autocorrelation); 1 when no clear cycle. maxPeriod defaults to min(floor(n/2), 24).
decompose(values: number[], period?: number) => Decomposition{ trend, seasonal, residual, period } - each an array the length of values. period defaults to detectPeriod(values).

A small residual standard deviation means trend + season explain almost all of the movement - i.e. how you separate real growth from "it's December again."

Changepoints: detectChangepoints

Finds the indices where the trend's slope structurally shifts (an OLS line is fit either side of every candidate split; local maxima of |slopeAfter - slopeBefore| above a threshold are kept).

changepoints · where the trend bends

Averages hide the moment a story changes. detectChangepoints() finds where the slope structurally shifts and the line is coloured by regime - here a clear peak-then-decline. Useful for "when did growth stall?" without eyeballing.

ts
const points = detectChangepoints(values, { minSegment: 3 });
// → [{ index, slopeBefore, slopeAfter, delta }, ...]
OptionTypeDefaultWhat it does
minSegmentnumber3Minimum points each side of a split (also the minimum gap between two changepoints).
thresholdnumberstdev of the slope-delta curveMinimum bend strength for a split to count; the default scales with the series.

Each Changepoint is { index, slopeBefore, slopeAfter, delta }, where index starts the "after" segment.

Simulation: monteCarloForecast

Runs many simulated futures around the point forecast (each step perturbed by Gaussian residual noise that grows with sqrt(step)), then reports the mean path, an empirical band, and exceedance probabilities. Deterministic via a seeded PRNG - the same seed always replays.

monteCarlo · a fan of futures

A single forecast line hides the risk. monteCarloForecast() runs hundreds of futures - each step nudged by random shocks - and reports the band (the shaded range) plus the odds of clearing a target. Deterministic via a seeded RNG, so the same seed always replays; Re-roll shows how the spread shifts.

ts
const mc = monteCarloForecast(values, { horizon: 4, runs: 500, seed: 1, level: 0.95 });
mc.predictions; // mean path (length = horizon)
mc.lower; mc.upper; // band edges per step
mc.probabilityAbove(target); // fraction of runs finishing strictly above `target`
mc.probabilityBelow(target);
OptionTypeDefaultWhat it does
horizonnumber(required)Steps to simulate forward.
runsnumber500Number of simulated futures.
seednumber1PRNG seed for reproducibility.
levelnumber0.95Central probability mass kept inside the band.
methodForecastMethod"holt-winters"Model used for the centre path.

Goal-seek: requiredGrowth / requiredRunRate / pacingToGoal

The inverse of forecasting: given a known target, what does it take to get there?

goal-seek · what hits the target

Forecasting runs time forward; goal-seek runs it backward from a target. requiredGrowth() and requiredRunRate() are plain arithmetic - the gold dashed line is the pace you'd have to hold. Change the target and watch it move.

ts
requiredGrowth(current, target, periods);   // constant % per period (compounding) to reach target
requiredRunRate(current, target, periods);  // constant additive amount per period to reach target
const pace = pacingToGoal(cumulative, target, periodsElapsed, periodsTotal);
// pace → { projected, attainmentPct, onTrack, requiredRunRate }
FunctionSignatureReturns
requiredGrowth(current, target, periods) => numberPer-period multiplicative growth g so current*(1+g)**periods === target. 0 when current <= 0 or periods <= 0.
requiredRunRate(current, target, periods) => numberPer-period additive increment (target - current) / periods.
pacingToGoal(cumulative, target, periodsElapsed, periodsTotal) => PacingProjects the current run-rate over the full window: { projected, attainmentPct, onTrack, requiredRunRate }.

All of these are pure arithmetic - no DOM, no deps, no randomness (except the seeded Monte Carlo).

Insights guide

Free and open source. MIT licensed.