Add Additional Method to XIRR if Newton-Raphson Does Not Converge (#3262)

Fix #689. XIRR is calculated by making guesses which are hopefully better with each iteration. It is not guaranteed to succeed for Excel, PhpSpreadsheet, or any other implementation. PhpSpreadsheet uses the Newton-Raphson method for its guesses. So does Python package xirr (https://github.com/tarioch/xirr/), but, if Newton-Raphson fails to converge, Python tries Brent's method as an alternative. Two sets of non-converging data are noted in 689. For both, a solution does converge in Excel. For the first of the problems, a solution converges in Python with Newton-Raphson; but, for the second, a solution converges which requires Brent. For the Java package https://github.com/RayDeCampo/java-xirr on which Python was based, and which uses only Newton-Raphson, a solution converges for the first, and does not converge for the second.

To try to match the good results of the others, I added an alternate algorithm if Newton-Raphson fails. Brent's algorithm seems difficult to implement to me. I might have gone there regardless, but I first tried a slightly simpler alternative, bisection. This solved the problem for both of the cases in 689. Perhaps someone will one day report a problem that doesn't converge for Newton-Raphson or bisection, but does for Brent. We can review this decision then.

The new code causes 3 changes in the unit test. In all 3 tests, Excel and PhpSpreadsheet had not converged, but Python and/or Java had. I now believe that Python/Java is correct in those cases, and Excel is not. The new code aligns PhpSpreadsheet with Python/Java for those tests. It is, of course, impossible to know when Excel's implementation doesn't converge, so we aren't guaranteed to match its results in those hopefully rare situations.
This commit is contained in:
oleibman
2022-12-27 08:49:17 -08:00
committed by GitHub
parent dd9a922b02
commit ec96ef3a6c
2 changed files with 88 additions and 12 deletions
@@ -51,8 +51,11 @@ class NonPeriodic
$f2 = self::xnpvOrdered($x2, $values, $dates, false);
$found = false;
for ($i = 0; $i < self::FINANCIAL_MAX_ITERATIONS; ++$i) {
if (!is_numeric($f1) || !is_numeric($f2)) {
break;
if (!is_numeric($f1)) {
return $f1;
}
if (!is_numeric($f2)) {
return $f2;
}
$f1 = (float) $f1;
$f2 = (float) $f2;
@@ -68,11 +71,32 @@ class NonPeriodic
$f2 = self::xnpvOrdered($x2, $values, $dates, false);
}
}
if (!$found) {
return ExcelError::NAN();
if ($found) {
return self::xirrPart3($values, $dates, $x1, $x2);
}
return self::xirrPart3($values, $dates, $x1, $x2);
// Newton-Raphson didn't work - try bisection
$x1 = $guess - 0.5;
$x2 = $guess + 0.5;
for ($i = 0; $i < self::FINANCIAL_MAX_ITERATIONS; ++$i) {
$f1 = self::xnpvOrdered($x1, $values, $dates, false, true);
$f2 = self::xnpvOrdered($x2, $values, $dates, false, true);
if (!is_numeric($f1) || !is_numeric($f2)) {
break;
}
if ($f1 * $f2 <= 0) {
$found = true;
break;
}
$x1 -= 0.5;
$x2 += 0.5;
}
if ($found) {
return self::xirrBisection($values, $dates, $x1, $x2);
}
return ExcelError::NAN();
}
/**
@@ -190,6 +214,45 @@ class NonPeriodic
return $rslt;
}
/**
* @return float|string
*/
private static function xirrBisection(array $values, array $dates, float $x1, float $x2)
{
$rslt = ExcelError::NAN();
for ($i = 0; $i < self::FINANCIAL_MAX_ITERATIONS; ++$i) {
$rslt = ExcelError::NAN();
$f1 = self::xnpvOrdered($x1, $values, $dates, false, true);
$f2 = self::xnpvOrdered($x2, $values, $dates, false, true);
if (!is_numeric($f1) || !is_numeric($f2)) {
break;
}
$f1 = (float) $f1;
$f2 = (float) $f2;
if (abs($f1) < self::FINANCIAL_PRECISION && abs($f2) < self::FINANCIAL_PRECISION) {
break;
}
if ($f1 * $f2 > 0) {
break;
}
$rslt = ($x1 + $x2) / 2;
$f3 = self::xnpvOrdered($rslt, $values, $dates, false, true);
if (!is_float($f3)) {
break;
}
if ($f3 * $f1 < 0) {
$x2 = $rslt;
} else {
$x1 = $rslt;
}
if (abs($f3) < self::FINANCIAL_PRECISION) {
break;
}
}
return $rslt;
}
/**
* @param mixed $rate
* @param mixed $values
@@ -197,7 +260,7 @@ class NonPeriodic
*
* @return float|string
*/
private static function xnpvOrdered($rate, $values, $dates, bool $ordered = true)
private static function xnpvOrdered($rate, $values, $dates, bool $ordered = true, bool $capAtNegative1 = false)
{
$rate = Functions::flattenSingleValue($rate);
$values = Functions::flattenArray($values);
@@ -206,6 +269,9 @@ class NonPeriodic
try {
self::validateXnpv($rate, $values, $dates);
if ($capAtNegative1 && $rate <= -1) {
$rate = -1.0 + 1.0E-10;
}
$date0 = DateTimeExcel\Helpers::getDateValue($dates[0]);
} catch (Exception $e) {
return $e->getMessage();
+16 -6
View File
@@ -69,6 +69,16 @@ return [
['2015-04-01', '2019-06-27'],
0.1,
],
'issue 689' => [
-0.6118824173,
[-1000000.706, 947003.58],
['2018-09-05', '2018-09-26'],
],
'issue 689 updated 2022-12-25' => [
-0.935842486,
[-20972.36, 20350.545],
['2022-12-12', '2022-12-16'],
],
'XIRR calculation #1 is incorrect' => [
0.137963527441025,
[139947.43, 1893.67, 52573.25, 48849.74, 26369.16, -273029.18],
@@ -186,13 +196,13 @@ return [
[-10000, 3027.25, 630.68, 2018.2, 1513.62, 1765.89, 4036.33, 4036.33, 1513.62, 1513.62, 2018.16, 1513.62, 1009.08, 1513.62, 1513.62, 1765.89, 1765.89, 22421.55],
['2000-05-24', '2000-06-05', '2001-04-09', '2004-02-24', '2005-03-18', '2006-02-15', '2007-01-10', '2007-11-14', '2008-12-17', '2010-01-15', '2011-01-14', '2012-02-03', '2013-01-18', '2014-01-24', '2015-01-30', '2016-01-22', '2017-01-20', '2017-06-05'],
],
'DeCampo issue5a, agree with Excel not DeCampo' => [
'#NUM!', //-0.7640294,
'DeCampo issue5a, agree with DeCampo not Excel' => [
-0.7640294, // '#NUM!'
[-2610, -2589, -5110, -2550, -5086, -2561, -5040, -2552, -2530, 29520],
['2001-06-22', '2001-07-03', '2001-07-05', '2001-07-06', '2001-07-09', '2001-07-10', '2001-07-12', '2001-07-13', '2001-07-16', '2001-07-17'],
],
'DeCampo issue5b, agree with Excel not DeCampo' => [
'#NUM!', //-0.8353404,
'DeCampo issue5b, agree with DeCampo not Excel' => [
-0.8353404, // '#NUM!'
[-2610, -2589, -5110, -2550, -5086, -2561, -5040, -2552, -2530, -9840, 38900],
['2001-06-22', '2001-07-03', '2001-07-05', '2001-07-06', '2001-07-09', '2001-07-10', '2001-07-12', '2001-07-13', '2001-07-16', '2001-07-17', '2001-07-18'],
],
@@ -206,8 +216,8 @@ return [
[-2236.3994659663, -47.3417585212, -46.52619316339632, 10424.74612565936, -13.077972551952],
['2017-12-16', '2017-12-26', '2017-12-29', '2017-12-31', '2017-12-20'],
],
'Python XIRR test line 39, agree with Excel not Python' => [
'#NUM!', //-1,
'Python XIRR test line 39, agree with Python not Excel' => [
-1, // '#NUM!',
[18902, 83600, -5780, -4080, -56780, -2210, -2380, 33975, 23067.98, -1619.57],
['2016-04-06', '2016-05-04', '2016-05-12', '2017-05-08', '2017-07-03', '2018-05-07', '2019-05-06', '2019-10-01', '2020-03-13', '2020-05-07'],
],