DEV Community

Alper San
Alper San

Posted on Originally published at sifting.io

CAGR in JavaScript: Don't Average Annual Returns

An arithmetic average of annual returns does not necessarily reproduce the ending value of a series. A CAGR calculation should. That difference is useful as a test when building a spreadsheet, a performance widget or a data-analysis function.

This example uses four synthetic valuations, exactly one year apart:

Observation Value Annual return
Start 100 Not applicable
Year 1 120 +20%
Year 2 90 −25%
Year 3 108 +20%

There are four values and three annual return periods. The cumulative return is 8%. The arithmetic mean of the annual percentages is 5%. The CAGR is approximately 2.5985568% per year.

All three outputs describe different measurements. Treating 5% as the constant annual growth rate would produce 115.7625 after three years, not 108.

Implement an explicit annual-series contract

The function below accepts positive finite numerical values at consecutive annual anniversaries, with no unhandled external contributions or withdrawals. It cannot establish those timing and cash-flow assumptions from the array; the caller must do that before calling it. Daily closes are a different input contract.

export function summarizeAnnualValues(values) {
  if (!Array.isArray(values) || values.length < 2 ||
      values.some(value => typeof value !== "number" ||
        !Number.isFinite(value) || value <= 0)) {
    throw new TypeError("Provide at least two positive finite annual values.");
  }

  const annualReturns = [];
  for (let i = 1; i < values.length; i++) {
    const factor = values[i] / values[i - 1];
    if (!Number.isFinite(factor) || factor <= 0) {
      throw new RangeError("Growth factor is outside the supported numeric range.");
    }
    annualReturns.push(factor - 1);
  }

  const years = values.length - 1;
  const cumulativeReturn = values.at(-1) / values[0] - 1;
  const arithmeticAverageAnnualReturn =
    annualReturns.reduce((sum, value) => sum + value, 0) / years;
  const cagr = Math.expm1(
    (Math.log(values.at(-1)) - Math.log(values[0])) / years
  );

  if (![cumulativeReturn, arithmeticAverageAnnualReturn, cagr]
      .every(Number.isFinite) || cagr <= -1) {
    throw new RangeError("Result is outside the supported numeric range.");
  }

  return { years, cumulativeReturn, arithmeticAverageAnnualReturn, cagr };
}

const result = summarizeAnnualValues([100, 120, 90, 108]);
// years: 3
// cumulativeReturn: approximately 0.08
// arithmeticAverageAnnualReturn: approximately 0.05
// cagr: approximately 0.025985568006
Enter fullscreen mode Exit fullscreen mode

The calculation returns decimal rates. Multiply by 100 when you need a numerical percentage, or use a percentage formatter directly on the decimal. Do not multiply twice.

The logarithms compute the equivalent annual endpoint growth without first raising an endpoint ratio to a fractional power. The checks reject invalid inputs and unsupported numeric ranges; they do not turn JavaScript's binary floating-point arithmetic into exact decimal accounting.

Test the ending-value identity

The essential invariant is:

start × (1 + CAGR)^years ≈ end
Enter fullscreen mode Exit fullscreen mode

Use a tolerance rather than exact equality for ordinary floating-point results. For the example, 100 * (1 + result.cagr) ** result.years should be close to 108. Compounding result.arithmeticAverageAnnualReturn instead should not pass that same test.

A second fixture is [100, 110, 99]. It gives a 0% arithmetic average, a −1% cumulative return and approximately −0.5012563% CAGR per year. The same 10% loss applies to a larger value than the preceding 10% gain.

Keep the data contract beside the result

Assumption Failure to catch upstream
Consecutive annual observations Daily values mistaken for yearly periods, or missing years ignored
Comparable value basis A share split changes the unit represented by a per-share price
Stated income treatment Price growth labelled total return without distributions and reinvestment
No unhandled external cash flows New deposits counted as investment performance
One reporting currency Unintended FX changes mixed into a comparison

For irregular dates, the caller needs an explicit elapsed-year calculation rather than values.length - 1. For a daily series, endpoint CAGR depends on elapsed time; an arithmetic mean of daily returns is not an arithmetic mean of annual returns.

Also define the words used in the UI. Here, “average annual return” means the arithmetic average of yearly observations. Some formal reports use “average annual total return” for a compounded measure, so importing a label does not establish its formula.

Further reading

The inputs are invented educational fixtures. The function summarizes a historical series under its stated assumptions; it does not forecast returns or measure the risk of the path.

Top comments (0)