mirror of
https://github.com/dream-num/univer.git
synced 2026-08-29 07:13:59 +08:00
fix(engine-formula): stabilize RATE function with fast-path and adaptive damping (#6055)
This commit is contained in:
committed by
GitHub
parent
cc3be15180
commit
8fbbb2d74e
@@ -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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user