Skip to content

Commit 4042b04

Browse files
authored
Fix IRR error when income > sum of payments (#1627)
### Context The IRR function returns `#NUM!` error when the initial investment significantly exceeds the sum of returns (e.g., `=IRR({-150000, 12000, 15000, 18000})`). Excel correctly returns ~-41% for this case. **Root cause:** The `irrCore` Newton-Raphson solver overshoots past the lower bound of -1 on the first iteration when the solution is a strongly negative rate. The code then unconditionally returns `#NUM!`. **Fix:** Replace the unconditional error with a bisection-based clamp. When Newton-Raphson overshoots past -1, bisect between the current rate and -1: `newRate = (rate - 1) / 2`. This is guaranteed to stay in the valid domain (`> -1`) and converges linearly until close enough for quadratic Newton convergence to take over. ### How did you test your changes? Added 5 unit tests in the private tests repo covering: - Bug reproduction: `[-150000, 12000, 15000, 18000]` with default guess - Reversed cash flow signs: `[150000, -12000, -15000, -18000]` - Highly negative IRR (near total loss): `[-10000, 100, 100, 100]` - Negative IRR with explicit guess - Large investment with many small returns All 42 IRR tests pass (37 existing + 5 new), no regressions. ### Types of changes - [x] Bug fix (a non-breaking change that fixes an issue) ### Related issues: 1. Fixes #1628 ### Checklist: - [x] I have reviewed the guidelines about [Contributing to HyperFormula](https://hyperformula.handsontable.com/guide/contributing.html) and I confirm that my code follows the code style of this project. - [ ] I have signed the [Contributor License Agreement](https://goo.gl/forms/yuutGuN0RjsikVpM2). - [x] My change is compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard. - [x] My change is compatible with Microsoft Excel. - [x] My change is compatible with Google Sheets. - [ ] I described my changes in the [CHANGELOG.md](https://github.com/handsontable/hyperformula/blob/master/CHANGELOG.md) file. - [ ] My changes require a documentation update. - [ ] My changes require a migration guide. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Small, localized numerical-solver change plus non-runtime test/benchmark script updates; primary risk is altered IRR convergence behavior on edge-case inputs. > > **Overview** > Fixes `IRR` returning `#NUM!` for strongly negative solutions by clamping Newton-Raphson iterations in `irrCore` when the next step overshoots past `-1` (bisects back into the valid domain instead of immediately erroring). > > Updates tooling/docs around the private test suite: renames the setup script to `test:setup-private`, adjusts `fetch-tests.sh` to create a missing branch from `develop` (and pull appropriately), and repoints benchmark scripts to `test/hyperformula-tests/performance`. Also records the IRR fix in `CHANGELOG.md`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 34b1265. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c96a600 commit 4042b04

5 files changed

Lines changed: 27 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Fixed the IRR function returning `#NUM!` error when the initial investment significantly exceeds the sum of returns. [#1628](https://github.com/handsontable/hyperformula/issues/1628)
13+
1014
## [3.2.0] - 2026-02-19
1115

1216
### Added

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,10 @@
7676
"verify:publish-package": "npm pack | node script/check-publish-package.js",
7777
"verify:typings": "tsc --noEmit",
7878
"test": "npm-run-all lint test:jest test:browser",
79-
"test:fetch-private": "bash test/fetch-tests.sh",
79+
"test:setup-private": "bash test/fetch-tests.sh",
8080
"test:jest": "cross-env NODE_ICU_DATA=node_modules/full-icu jest",
8181
"test:watch": "npm run test:jest -- --watch",
82-
"test:tdd": "npm run test:jest -- --watch function-value",
82+
"test:tmp": "npm run test:jest -- --watch function-irr",
8383
"test:coverage": "npm run test:jest -- --coverage",
8484
"test:logMemory": "npm run test:jest -- --runInBand --logHeapUsage",
8585
"test:performance": "npm run benchmark:basic && npm run benchmark:cruds",
@@ -88,10 +88,10 @@
8888
"test:browser": "cross-env-shell BABEL_ENV=dist env-cmd -f ht.config.js karma start",
8989
"test:browser.debug": "cross-env-shell BABEL_ENV=dist NODE_ENV=debug env-cmd -f ht.config.js karma start",
9090
"typedoc:build-api": "cross-env NODE_OPTIONS=--openssl-legacy-provider typedoc --options .typedoc.md.ts",
91-
"benchmark:basic": "npm run tsnode test/performance/run-basic-benchmark.ts",
92-
"benchmark:cruds": "npm run tsnode test/performance/run-cruds-benchmark.ts",
93-
"benchmark:write-to-file": "npm run tsnode test/performance/write-to-file.ts",
94-
"benchmark:compare-benchmarks": "npm run tsnode test/performance/compare-benchmarks.ts",
91+
"benchmark:basic": "npm run tsnode test/hyperformula-tests/performance/run-basic-benchmark.ts",
92+
"benchmark:cruds": "npm run tsnode test/hyperformula-tests/performance/run-cruds-benchmark.ts",
93+
"benchmark:write-to-file": "npm run tsnode test/hyperformula-tests/performance/write-to-file.ts",
94+
"benchmark:compare-benchmarks": "npm run tsnode test/hyperformula-tests/performance/compare-benchmarks.ts",
9595
"lint": "eslint . --ext .js,.ts",
9696
"lint:fix": "eslint . --ext .js,.ts --fix",
9797
"audit": "npm audit --omit=dev",

src/interpreter/plugin/FinancialPlugin.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -876,19 +876,23 @@ function irrCore(values: number[], guess: number): number | CellError {
876876
}
877877

878878
// Newton-Raphson step
879-
const newRate = rate - npv / dnpv
879+
let newRate = rate - npv / dnpv
880+
881+
if (!isFinite(newRate)) {
882+
return new CellError(ErrorType.NUM)
883+
}
884+
885+
// Clamp: when Newton overshoots past -1, bisect between current rate and -1
886+
if (newRate <= -1) {
887+
newRate = (rate - 1) / 2
888+
}
880889

881890
// Check for convergence based on rate change
882891
if (Math.abs(newRate - rate) < epsMax) {
883892
return newRate
884893
}
885894

886895
rate = newRate
887-
888-
// Check for invalid rate
889-
if (!isFinite(rate) || rate <= -1) {
890-
return new CellError(ErrorType.NUM)
891-
}
892896
}
893897

894898
return new CellError(ErrorType.NUM)

test/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The private test suite is kept in the `hyperformula-tests` repository. Once you
1212

1313
Whenenever you switch branch in the main repository, you need to fetch the private test suite to the `test/hyperformula-tests` directory by running:
1414
```
15-
npm run test:fetch-private
15+
npm run test:setup-private
1616
1717
```
1818

@@ -45,7 +45,7 @@ This file is located in the `test` directory.
4545
3. **Checkout matching branch** – In `hyperformula-tests`:
4646
- Fetches from `origin`
4747
- Checks out the branch if it exists locally or on `origin`
48-
- Otherwise checks out `develop`
48+
- If the branch doesn't exist, creates it from `develop`
4949

5050
4. **Pull latest** – Runs `git pull origin` on the checked-out branch.
5151

test/fetch-tests.sh

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,17 @@ fi
2828

2929
echo "Checking out branch $CURRENT_BRANCH in hyperformula-tests..."
3030

31-
# 3. Checkout matching branch in hyperformula-tests or fall back to develop
31+
# 3. Checkout matching branch in hyperformula-tests or create it if it doesn't exist
3232
cd "$HYPERFORMULA_TESTS_DIR"
3333
git fetch origin
3434

3535
if git show-ref --verify --quiet "refs/heads/$CURRENT_BRANCH" || \
3636
git show-ref --verify --quiet "refs/remotes/origin/$CURRENT_BRANCH"; then
3737
git checkout "$CURRENT_BRANCH"
38+
git pull # pull latest changes
3839
else
40+
echo "Branch $CURRENT_BRANCH not found in hyperformula-tests, creating from develop..."
3941
git checkout develop
42+
git pull origin develop
43+
git checkout -b "$CURRENT_BRANCH"
4044
fi
41-
42-
# 4. Pull changes from origin
43-
git pull origin "$(git rev-parse --abbrev-ref HEAD)"

0 commit comments

Comments
 (0)