fix(engine-formula): stabilize RATE function with fast-path and adaptive damping (#6055)

This commit is contained in:
Enajjachi Zakariaa
2025-11-29 04:49:31 +01:00
committed by GitHub
parent cc3be15180
commit 8fbbb2d74e
2 changed files with 155 additions and 24 deletions
@@ -167,5 +167,63 @@ describe('Test rate function', () => {
);
expect(typeof getObjectValue(result)).toBe('number');
});
// Edge cases for growth-only / zero-guess-
it('Correctly calculates long-term growth-only rate even with zero guess', () => {
const result = testFunction.calculate(
NumberValueObject.create(90),
NumberValueObject.create(0),
NumberValueObject.create(-1000),
NumberValueObject.create(7_500_000),
NumberValueObject.create(0),
NumberValueObject.create(0) // zero guess
);
expect(getObjectValue(result, true)).toBeCloseTo(0.10422, 5);
});
it('Rejects invalid sign convention with PMT=0', () => {
const result = testFunction.calculate(
NumberValueObject.create(90),
NumberValueObject.create(0),
NumberValueObject.create(1000),
NumberValueObject.create(7_500_000)
);
expect(getObjectValue(result)).toBe(ErrorType.NUM); // PV and FV same sign
});
// Test type parameter boolean conversion: non-zero values should be treated as 1, only 0 as 0
it('Converts type parameter using standard boolean conversion (non-zero = 1, zero = 0)', () => {
const nper = NumberValueObject.create(48);
const pmt = NumberValueObject.create(-1000);
const pv = NumberValueObject.create(100000);
const fv = NumberValueObject.create(0);
const guess = NumberValueObject.create(0.1);
// type = 0 should give one result
const result0 = testFunction.calculate(nper, pmt, pv, fv, NumberValueObject.create(0), guess);
const value0 = getObjectValue(result0, true) as number;
// type = 1 should give a different result
const result1 = testFunction.calculate(nper, pmt, pv, fv, NumberValueObject.create(1), guess);
const value1 = getObjectValue(result1, true) as number;
// Verify 0 and 1 produce different results
expect(value0).not.toBe(value1);
// Non-zero values (0.2, -1, 0.49) should be treated as 1 (true)
const result02 = testFunction.calculate(nper, pmt, pv, fv, NumberValueObject.create(0.2), guess);
const resultNeg1 = testFunction.calculate(nper, pmt, pv, fv, NumberValueObject.create(-1), guess);
const result049 = testFunction.calculate(nper, pmt, pv, fv, NumberValueObject.create(0.49), guess);
// All non-zero values should match type=1, not type=0
expect(getObjectValue(result02, true)).toBe(value1);
expect(getObjectValue(resultNeg1, true)).toBe(value1);
expect(getObjectValue(result049, true)).toBe(value1);
// Verify they don't match type=0
expect(getObjectValue(result02, true)).not.toBe(value0);
expect(getObjectValue(resultNeg1, true)).not.toBe(value0);
expect(getObjectValue(result049, true)).not.toBe(value0);
});
});
});
@@ -86,9 +86,12 @@ export class Rate extends BaseFunction {
return ErrorValueObject.create(ErrorType.NUM);
}
// ? return error whenever the cash flows doesnt make economic sense
if ((pmtValue >= 0 && pvValue >= 0 && fvValue >= 0) ||
(pmtValue <= 0 && pvValue <= 0 && fvValue <= 0)) {
// Cash flow sanity check - prevent all-positive or all-negative cash flows
// Zero values are considered neutral (valid for both signs)
const allPositive = pmtValue >= 0 && pvValue >= 0 && fvValue >= 0;
const allNegative = pmtValue <= 0 && pvValue <= 0 && fvValue <= 0;
if (allPositive || allNegative) {
return ErrorValueObject.create(ErrorType.NUM);
}
@@ -113,45 +116,115 @@ export class Rate extends BaseFunction {
columnIndex: number
): BaseValueObject {
const epsMax = 1e-10;
const iterMax = 20;
const iterMax = 100;
// Fast path for PMT == 0
if (Math.abs(pmtValue) < 1e-14) {
return this._computeSimpleGrowthRate(nperValue, pvValue, fvValue, rowIndex, columnIndex);
}
let result = guessValue;
let lastResidual = Number.POSITIVE_INFINITY;
for (let i = 0; i < iterMax; i++) {
if (result <= -1) {
return ErrorValueObject.create(ErrorType.NUM);
}
let y, f;
const { value: y, derivative: dy } = this._evaluateRateFunction(
result,
nperValue,
pmtValue,
pvValue,
fvValue,
typeValue,
epsMax
);
if (Math.abs(result) < epsMax) {
y = pvValue * (1 + nperValue * result) + pmtValue * (1 + result * typeValue) * nperValue + fvValue;
} else {
f = (1 + result) ** nperValue;
y = pvValue * f + pmtValue * (1 / result + typeValue) * (f - 1) + fvValue;
}
const residual = Math.abs(y);
if (Math.abs(y) < epsMax) {
if (residual < epsMax) {
break;
}
let dy;
if (Math.abs(result) < epsMax) {
dy = pvValue * nperValue + pmtValue * typeValue * nperValue;
} else {
f = (1 + result) ** nperValue;
const df = nperValue * (1 + result) ** (nperValue - 1);
dy = pvValue * df + pmtValue * (1 / result + typeValue) * df + pmtValue * (-1 / (result * result)) * (f - 1);
if (Math.abs(dy) < 1e-14) {
break;
}
result -= y / dy;
// Adaptive damping: larger steps when far from solution, smaller when close
const dampedStep = this._getAdaptiveDampedStep(y, dy, residual, lastResidual);
result -= dampedStep;
lastResidual = residual;
}
if (rowIndex === 0 && columnIndex === 0) {
return NumberValueObject.create(result, '0%');
// Only reject rates that are mathematically invalid or non-finite
if (!Number.isFinite(result) || result <= -1) {
return ErrorValueObject.create(ErrorType.NUM);
}
return NumberValueObject.create(result, rowIndex === 0 && columnIndex === 0 ? '0%' : undefined);
}
private _computeSimpleGrowthRate(
nperValue: number,
pvValue: number,
fvValue: number,
rowIndex: number,
columnIndex: number
): BaseValueObject {
if (Math.sign(pvValue) === Math.sign(fvValue)) {
return ErrorValueObject.create(ErrorType.NUM);
}
const r = (fvValue / -pvValue) ** (1 / nperValue) - 1;
return NumberValueObject.create(r, rowIndex === 0 && columnIndex === 0 ? '0%' : undefined);
}
private _evaluateRateFunction(
rate: number,
nperValue: number,
pmtValue: number,
pvValue: number,
fvValue: number,
typeValue: number,
epsMax: number
): { value: number; derivative: number } {
let y: number;
let dy: number;
let f: number;
if (Math.abs(rate) < epsMax) {
// Use Taylor series limit as rate approaches 0
y = pvValue * (1 + nperValue * rate) + pmtValue * (1 + rate * typeValue) * nperValue + fvValue;
dy = pvValue * nperValue + pmtValue * typeValue * nperValue;
} else {
return NumberValueObject.create(result);
// Standard evaluation
f = (1 + rate) ** nperValue;
y = pvValue * f + pmtValue * (1 / rate + typeValue) * (f - 1) + fvValue;
const df = nperValue * (1 + rate) ** (nperValue - 1);
dy = pvValue * df + pmtValue * (1 / rate + typeValue) * df + pmtValue * (-1 / (rate * rate)) * (f - 1);
}
return { value: y, derivative: dy };
}
private _getAdaptiveDampedStep(
y: number,
dy: number,
residual: number,
lastResidual: number
): number {
const rawStep = y / dy;
// When residual is large (>1e-3) or not improving, allow larger steps
// When residual is small (<1e-3), use tighter damping for precision
if (residual > 1e-3 || residual >= lastResidual * 0.9) {
// Far from solution - allow steps up to ±5.0
return Math.max(Math.min(rawStep, 5.0), -5.0);
} else {
// Close to solution - use conservative damping for stability
return Math.max(Math.min(rawStep, 0.5), -0.5);
}
}
}