diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index db35c729..29a23d86 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -930,11 +930,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/InterestAndPrincipal.php - - - message: "#^Parameter \\#4 \\$x2 of static method PhpOffice\\\\PhpSpreadsheet\\\\Calculation\\\\Financial\\\\CashFlow\\\\Variable\\\\NonPeriodic\\:\\:xirrPart3\\(\\) expects float, mixed given\\.$#" - count: 1 - path: src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Calculation\\\\Financial\\\\CashFlow\\\\Variable\\\\Periodic\\:\\:presentValue\\(\\) has parameter \\$args with no type specified\\.$#" count: 1 @@ -7915,11 +7910,6 @@ parameters: count: 1 path: tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XNpvTest.php - - - message: "#^Parameter \\#3 \\$message of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertEquals\\(\\) expects string, mixed given\\.$#" - count: 1 - path: tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XirrTest.php - - message: "#^Part \\$number \\(mixed\\) of encapsed string cannot be cast to string\\.$#" count: 1 diff --git a/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php b/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php index 8986146c..d590e1a4 100644 --- a/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php +++ b/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php @@ -12,6 +12,8 @@ class NonPeriodic const FINANCIAL_PRECISION = 1.0e-08; + const DEFAULT_GUESS = 0.1; + /** * XIRR. * @@ -25,11 +27,11 @@ class NonPeriodic * @param mixed[] $dates A series of payment dates * The first payment date indicates the beginning of the schedule of payments * All other dates must be later than this date, but they may occur in any order - * @param float $guess An optional guess at the expected answer + * @param mixed $guess An optional guess at the expected answer * * @return float|string */ - public static function rate($values, $dates, $guess = 0.1) + public static function rate($values, $dates, $guess = self::DEFAULT_GUESS) { $rslt = self::xirrPart1($values, $dates); if ($rslt !== '') { @@ -37,9 +39,13 @@ class NonPeriodic } // create an initial range, with a root somewhere between 0 and guess - $guess = Functions::flattenSingleValue($guess); + $guess = Functions::flattenSingleValue($guess) ?? self::DEFAULT_GUESS; + if (!is_numeric($guess)) { + return Functions::VALUE(); + } + $guess = ($guess + 0.0) ?: self::DEFAULT_GUESS; $x1 = 0.0; - $x2 = $guess ?: 0.1; + $x2 = $guess + 0.0; $f1 = self::xnpvOrdered($x1, $values, $dates, false); $f2 = self::xnpvOrdered($x2, $values, $dates, false); $found = false; @@ -54,9 +60,11 @@ class NonPeriodic break; } elseif (abs($f1) < abs($f2)) { - $f1 = self::xnpvOrdered($x1 += 1.6 * ($x1 - $x2), $values, $dates, false); + $x1 += 1.6 * ($x1 - $x2); + $f1 = self::xnpvOrdered($x1, $values, $dates, false); } else { - $f2 = self::xnpvOrdered($x2 += 1.6 * ($x2 - $x1), $values, $dates, false); + $x2 += 1.6 * ($x2 - $x1); + $f2 = self::xnpvOrdered($x2, $values, $dates, false); } } if (!$found) { @@ -104,11 +112,13 @@ class NonPeriodic */ private static function xirrPart1(&$values, &$dates): string { - if (!is_array($values) && !is_array($dates)) { - return Functions::NA(); - } $values = Functions::flattenArray($values); $dates = Functions::flattenArray($dates); + $valuesIsArray = count($values) > 1; + $datesIsArray = count($dates) > 1; + if (!$valuesIsArray && !$datesIsArray) { + return Functions::NA(); + } if (count($values) != count($dates)) { return Functions::NAN(); } @@ -219,7 +229,11 @@ class NonPeriodic if (!is_numeric($dif)) { return $dif; } - $xnpv += $values[$i] / (1 + $rate) ** ($dif / 365); + if ($rate <= -1.0) { + $xnpv += -abs($values[$i]) / (-1 - $rate) ** ($dif / 365); + } else { + $xnpv += $values[$i] / (1 + $rate) ** ($dif / 365); + } } return is_finite($xnpv) ? $xnpv : Functions::VALUE(); diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/AllSetupTeardown.php b/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/AllSetupTeardown.php new file mode 100644 index 00000000..66ff4fc8 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/AllSetupTeardown.php @@ -0,0 +1,116 @@ +compatibilityMode = Functions::getCompatibilityMode(); + } + + protected function tearDown(): void + { + Functions::setCompatibilityMode($this->compatibilityMode); + $this->sheet = null; + if ($this->spreadsheet !== null) { + $this->spreadsheet->disconnectWorksheets(); + $this->spreadsheet = null; + } + } + + protected static function setOpenOffice(): void + { + Functions::setCompatibilityMode(Functions::COMPATIBILITY_OPENOFFICE); + } + + protected static function setGnumeric(): void + { + Functions::setCompatibilityMode(Functions::COMPATIBILITY_GNUMERIC); + } + + /** + * @param mixed $expectedResult + */ + protected function mightHaveException($expectedResult): void + { + if ($expectedResult === 'exception') { + $this->expectException(CalcException::class); + } + } + + /** + * @param mixed $value + */ + protected function setCell(string $cell, $value): void + { + if ($value !== null) { + if (is_string($value) && is_numeric($value)) { + $this->getSheet()->getCell($cell)->setValueExplicit($value, DataType::TYPE_STRING); + } else { + $this->getSheet()->getCell($cell)->setValue($value); + } + } + } + + protected function getSpreadsheet(): Spreadsheet + { + if ($this->spreadsheet !== null) { + return $this->spreadsheet; + } + $this->spreadsheet = new Spreadsheet(); + + return $this->spreadsheet; + } + + protected function getSheet(): Worksheet + { + if ($this->sheet !== null) { + return $this->sheet; + } + $this->sheet = $this->getSpreadsheet()->getActiveSheet(); + + return $this->sheet; + } + + /** + * Adjust result if it is close enough to expected by ratio + * rather than offset. + * + * @param mixed $result + * @param mixed $expectedResult + */ + protected function adjustResult(&$result, $expectedResult): void + { + if (is_numeric($result) && is_numeric($expectedResult)) { + if ($expectedResult != 0) { + $frac = $result / $expectedResult; + if ($frac > 0.999999 && $frac < 1.000001) { + $result = $expectedResult; + } + } + } + } +} diff --git a/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XirrTest.php b/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XirrTest.php index a6677f3f..e5808a50 100644 --- a/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XirrTest.php +++ b/tests/PhpSpreadsheetTests/Calculation/Functions/Financial/XirrTest.php @@ -2,35 +2,60 @@ namespace PhpOffice\PhpSpreadsheetTests\Calculation\Functions\Financial; -use PhpOffice\PhpSpreadsheet\Calculation\Financial; -use PhpOffice\PhpSpreadsheet\Calculation\Functions; -use PHPUnit\Framework\TestCase; - -class XirrTest extends TestCase +class XirrTest extends AllSetupTeardown { - protected function setUp(): void - { - Functions::setCompatibilityMode(Functions::COMPATIBILITY_EXCEL); - } - /** * @dataProvider providerXIRR * * @param mixed $expectedResult - * @param mixed $message + * @param string $message + * @param mixed $values + * @param mixed $dates + * @param mixed $guess */ - public function testXIRR($expectedResult, $message, ...$args): void + public function testXIRR($expectedResult, $message, $values = null, $dates = null, $guess = null): void { - $result = Financial::XIRR(...$args); - if (is_numeric($result) && is_numeric($expectedResult)) { - if ($expectedResult != 0) { - $frac = $result / $expectedResult; - if ($frac > 0.999999 && $frac < 1.000001) { - $result = $expectedResult; + $this->mightHaveException($expectedResult); + $sheet = $this->getSheet(); + $formula = '=XIRR('; + if ($values !== null) { + if (is_array($values)) { + $row = 0; + foreach ($values as $value) { + ++$row; + $sheet->getCell("A$row")->setValue($value); + } + $formula .= "A1:A$row"; + } else { + $sheet->getCell('A1')->setValue($values); + $formula .= 'A1'; + } + if ($dates !== null) { + if (is_array($dates)) { + $row = 0; + foreach ($dates as $date) { + ++$row; + $sheet->getCell("B$row")->setValue($date); + } + $formula .= ",B1:B$row"; + } else { + $sheet->getCell('B1')->setValue($dates); + $formula .= ',B1'; + } + if ($guess !== null) { + if ($guess !== 'C1') { + $sheet->getCell('C1')->setValue($guess); + } + $formula .= ', C1'; } } } - self::assertEquals($expectedResult, $result, $message); + $formula .= ')'; + $sheet->getCell('D1')->setValue($formula); + $result = $sheet->getCell('D1')->getCalculatedValue(); + $this->adjustResult($result, $expectedResult); + + self::assertEqualsWithDelta($expectedResult, $result, 0.1E-7, $message); } public function providerXIRR(): array diff --git a/tests/data/Calculation/Financial/XIRR.php b/tests/data/Calculation/Financial/XIRR.php index 3414d132..6b7a7b9a 100644 --- a/tests/data/Calculation/Financial/XIRR.php +++ b/tests/data/Calculation/Financial/XIRR.php @@ -59,6 +59,13 @@ return [ '2015-01-04', 0.1, ], + [ + '#VALUE!', + 'Return VALUE error if guess is non-numeric', + [1893.67, 139947.43, 52573.25, 48849.74, 26369.16, -273029.18], + ['2019-06-27', '2019-06-20', '2019-06-21', '2019-06-24', '2019-06-27', '2019-07-27'], + 'XYZ', + ], [ 0.137963527441025, 'Dates can be in any order after all', @@ -118,10 +125,129 @@ return [ ['2019-06-20', '2019-06-27', '2019-06-21', '2019-06-24', '2019-06-27', '2019-07-27'], 0.00000, ], + [ + 0.13796353, + 'Substitute when guess is empty cell', + [139947.43, 1893.67, 52573.25, 48849.74, 26369.16, -273029.18], + ['2019-06-20', '2019-06-27', '2019-06-21', '2019-06-24', '2019-06-27', '2019-07-27'], + 'C1', + ], [ '#NUM!', 'Can\'t find a result2 that works after FINANCIAL_MAX_ITERATIONS tries, the #NUM! error value is returned', [-10000, 10000, -10000, 5], ['2010-01-15', '2010-04-16', '2010-07-16', '2010-10-15'], ], + [ + -0.642307613, + 'See issue #2469 - non-convergence with initial guess', + [55600, -51094.83], + ['2021-11-24', '2021-12-24'], + ], + [ + -0.642307613, + 'See issue #2469 - non-convergence with initial guess equal to correct answer', + [55600, -51094.83], + ['2021-11-24', '2021-12-24'], + -0.642307613, + ], + [ + 'exception', + 'Only one argument should cause exception', + ['2021-11-24', '2021-12-24'], + ], + [ + 'exception', + 'No argument should cause exception', + ], + [ + 0, + 'DeCampo One year no growth', + [-1000, 1000], + ['2010-01-01', '2011-01-01'], + ], + [ + 0.1, + 'DeCampo One year growth', + [-1000, 1100], + ['2010-01-01', '2011-01-01'], + ], + [ + -0.1, + 'DeCampo One year decline', + [-1000, 900], + ['2010-01-01', '2011-01-01'], + ], + [ + 0.1212676, + 'DeCampo vs spreadsheet', + [-1000, -1000, -1000, -1000, 4300], + ['2010-01-01', '2010-04-01', '2010-07-01', '2010-10-01', '2011-01-01'], + ], + [ + 0.1212676, + 'DeCampo vs spreadsheet reordered', + [-1000, 4300, -1000, -1000, -1000], + ['2010-10-01', '2011-01-01', '2010-07-01', '2010-01-01', '2010-04-01'], + ], + [ + 2.0, + 'DeCampo Over 100% growth', + [-1000, 3000], + ['2010-01-01', '2011-01-01'], + ], + [ + '#NUM!', // -1.0, DeCampo accounts for this case, Excel doesn't + 'DeCampo Total loss one year, agree with Excel not DeCampo', + [-1000, 0], + ['2010-01-01', '2011-01-01'], + ], + [ + '#NUM!', // -1.0, DeCampo accounts for this case, Excel doesn't + 'DeCampo Total loss two years, agree with Excel not DeCampo', + [-1000, 0], + ['2010-01-01', '2012-01-01'], + ], + [ + 0.2504234710540838, + 'DeCampo Readme example', + [-1000, -2500, -1000, 5050], + ['2016-01-15', '2016-02-08', '2016-04-17', '2016-08-24'], + ], + [ + 0.2126861, + 'DeCampo from nodejs', + [-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'], + ], + [ + '#NUM!', //-0.7640294, + 'DeCampo issue5a, agree with Excel not DeCampo', + [-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'], + ], + [ + '#NUM!', //-0.8353404, + 'DeCampo issue5b, agree with Excel not DeCampo', + [-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'], + ], + [ + 412461.6383, + 'Python XIRR test line 20', + [-100, 1000], + ['2019-12-31', '2020-03-05'], + ], + [ + 1.223853529e16, + 'Python XIRR test line 21', + [-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'], + ], + [ + '#NUM!', //-1, + 'Python XIRR test line 39, agree with Excel not Python', + [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'], + ], ];