diff --git a/packages/engine-formula/src/functions/financial/rate/__tests__/index.spec.ts b/packages/engine-formula/src/functions/financial/rate/__tests__/index.spec.ts index db10208812..a77449e2b5 100644 --- a/packages/engine-formula/src/functions/financial/rate/__tests__/index.spec.ts +++ b/packages/engine-formula/src/functions/financial/rate/__tests__/index.spec.ts @@ -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); + }); }); }); diff --git a/packages/engine-formula/src/functions/financial/rate/index.ts b/packages/engine-formula/src/functions/financial/rate/index.ts index 8337532b54..a58fd304c2 100644 --- a/packages/engine-formula/src/functions/financial/rate/index.ts +++ b/packages/engine-formula/src/functions/financial/rate/index.ts @@ -86,9 +86,12 @@ export class Rate extends BaseFunction { return ErrorValueObject.create(ErrorType.NUM); } - // ? return error whenever the cash flows doesn’t 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); } } }