diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8858690..6c8d466d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added a new function: IRR. [#1591](https://github.com/handsontable/hyperformula/issues/1591) + ## [3.1.1] - 2025-12-18 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5a11d26cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +HyperFormula is a headless spreadsheet engine written in TypeScript. It parses and evaluates Excel-compatible formulas and can run in browser or Node.js environments. The library implements ~400 built-in functions with support for custom functions, undo/redo, CRUD operations, and i18n (17 languages). + +## Build & Development Commands + +```bash +npm install # Install dependencies +npm run compile # TypeScript compilation to lib/ +npm run bundle-all # Full build: compile + bundle all formats +npm run lint # Run ESLint +npm run lint:fix # Auto-fix lint issues +``` + +## Testing + +```bash +npm test # Full suite: lint + unit + browser + compatibility +npm run test:unit # Jest unit tests only +npm run test:watch # Jest watch mode (run tests on file changes) +npm run test:coverage # Unit tests with coverage report +npm run test:browser # Karma browser tests (Chrome/Firefox) +npm run test:performance # Run performance benchmarks +npm run test:compatibility # Excel compatibility tests +``` + +Test files are located in `test/unit/` and follow the pattern `*.spec.ts`. + +## Architecture + +### Core Components + +- **`src/HyperFormula.ts`** - Main engine class, public API entry point +- **`src/parser/`** - Formula parsing using Chevrotain parser generator +- **`src/interpreter/`** - Formula evaluation engine +- **`src/DependencyGraph/`** - Cell dependency tracking and recalculation order +- **`src/CrudOperations.ts`** - Create/Read/Update/Delete operations on sheets and cells + +### Function Plugins (`src/interpreter/plugin/`) + +All spreadsheet functions are implemented as plugins extending `FunctionPlugin`. Each plugin: +- Declares `implementedFunctions` static property mapping function names to metadata +- Uses `runFunction()` helper for argument validation, coercion, and array handling +- Registers function translations in `src/i18n/languages/` + +To add a new function: +1. Create or modify a plugin in `src/interpreter/plugin/` +2. Add function metadata to `implementedFunctions` +3. Implement the function method +4. Add translations to all language files in `src/i18n/languages/` +5. Add tests in `test/unit/interpreter/` + +### i18n (`src/i18n/languages/`) + +Function name translations for each supported language. When adding new functions, translations can be found at: +- https://support.microsoft.com/en-us/office/excel-functions-translator-f262d0c0-991c-485b-89b6-32cc8d326889 +- http://dolf.trieschnigg.nl/excel/index.php + +## Output Formats + +The build produces multiple output formats: +- `commonjs/` - CommonJS modules (main entry) +- `es/` - ES modules (.mjs files) +- `dist/` - UMD bundles for browsers +- `typings/` - TypeScript declaration files + +## Contributing Guidelines + +- Create feature branches, never commit directly to master +- Target the `develop` branch for pull requests +- Add tests for all changes in `test/` folder +- Run linter before submitting (`npm run lint`) +- Maintain compatibility with Excel and Google Sheets behavior +- In documentation, commit messages, pull request descriptions and code comments, do not mention Claude Code nor LLM models used for code generation + +## Response Guidelines + +- By default speak ultra-concisely, using as few words as you can, unless asked otherwise. +- Focus solely on instructions and provide relevant responses. +- Ask questions to remove ambiguity and make sure you're speaking about the right thing. +- Ask questions if you need more information to provide an accurate answer. +- If you don't know something, simply say, "I don't know," and ask for help. +- Present your answer in a structured way, use bullet lists, numbered lists, tables, etc. +- When asked for specific content, start the response with the requested info immediately. +- When answering based on context, support your claims by quoting exact fragments of available documents. + +## Code Style + +- When generating code, prefer functional approach whenever possible (in JS/TS use filter, map and reduce functions). +- Make the code self-documenting. Use meaningfull names for classes, functions, valiables etc. Add code comments only when necessary. +- Add jsdocs to all classes and functions. diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index 07d4075c6..7b428c1b6 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -176,6 +176,7 @@ Total number of functions: **{{ $page.functionsCount }}** | FV | Returns the future value of an investment. | FV(Rate, Nper, Pmt[, Pv,[ Type]]) | | FVSCHEDULE | Returns the future value of an investment based on a rate schedule. | FV(Pv, Schedule) | | IPMT | Returns the interest portion of a given loan payment in a given payment period. | IPMT(Rate, Per, Nper, Pv[, Fv[, Type]]) | +| IRR | Returns the internal rate of return for a series of cash flows. | IRR(Values[, Guess]) | | ISPMT | Returns the interest paid for a given period of an investment with equal principal payments. | ISPMT(Rate, Per, Nper, Value) | | MIRR | Returns modified internal value for cashflows. | MIRR(Flows, FRate, RRate) | | NOMINAL | Returns the nominal interest rate. | NOMINAL(Effect_rate, Npery) | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 2be3e13f4..b5e64d348 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'CELÁ.ČÁST', INTERVAL: 'INTERVAL', //FIXME IPMT: 'PLATBA.ÚROK', + IRR: 'MÍRA.VÝNOSNOSTI', ISBINARY: 'ISBINARY', ISBLANK: 'JE.PRÁZDNÉ', ISERR: 'JE.CHYBA', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 0250cfe90..dbf971561 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'HELTAL', INTERVAL: 'INTERVAL', //FIXME IPMT: 'R.YDELSE', + IRR: 'IA', ISBINARY: 'ISBINARY', ISBLANK: 'ER.TOM', ISERR: 'ER.FJL', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index c15e14765..716a260c2 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'GANZZAHL', INTERVAL: 'INTERVAL', //FIXME IPMT: 'ZINSZ', + IRR: 'IKV', ISBINARY: 'ISBINARY', ISBLANK: 'ISTLEER', ISERR: 'ISTFEHL', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 5863d20da..ff4e73bdb 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -112,6 +112,7 @@ const dictionary: RawTranslationPackage = { INT: 'INT', INTERVAL: 'INTERVAL', IPMT: 'IPMT', + IRR: 'IRR', ISBINARY: 'ISBINARY', ISBLANK: 'ISBLANK', ISERR: 'ISERR', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 1f98606d7..3de8fe4e8 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -111,6 +111,7 @@ export const dictionary: RawTranslationPackage = { INT: 'ENTERO', INTERVAL: 'INTERVAL', //FIXME IPMT: 'PAGOINT', + IRR: 'TIR', ISBINARY: 'ISBINARY', ISBLANK: 'ESBLANCO', ISERR: 'ESERR', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 0ef873713..3e38400f8 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'KOKONAISLUKU', INTERVAL: 'INTERVAL', //FIXME IPMT: 'IPMT', + IRR: 'SISÄINEN.KORKO', ISBINARY: 'ISBINARY', ISBLANK: 'ONTYHJÄ', ISERR: 'ONVIRH', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index bc7b589d9..c38a9b3f0 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'ENT', INTERVAL: 'INTERVAL', //FIXME IPMT: 'INTPER', + IRR: 'TRI', ISBINARY: 'ISBINARY', ISBLANK: 'ESTVIDE', ISERR: 'ESTERR', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 4a5c918d2..fee4ce1cb 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'INT', INTERVAL: 'INTERVAL', //FIXME IPMT: 'RRÉSZLET', + IRR: 'BMR', ISBINARY: 'ISBINARY', ISBLANK: 'ÜRES', ISERR: 'HIBA.E', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 0c6197541..73824f318 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'INT', INTERVAL: 'INTERVAL', //FIXME IPMT: 'INTERESSI', + IRR: 'TIR.COST', ISBINARY: 'ISBINARY', ISBLANK: 'VAL.VUOTO', ISERR: 'VAL.ERR', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 066d32d45..501439b2b 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'HELTALL', INTERVAL: 'INTERVAL', //FIXME IPMT: 'RAVDRAG', + IRR: 'IR', ISBINARY: 'ISBINARY', ISBLANK: 'ERTOM', ISERR: 'ERF', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index ae72bfd45..6e25af59f 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'INTEGER', INTERVAL: 'INTERVAL', //FIXME IPMT: 'IBET', + IRR: 'IR', ISBINARY: 'ISBINARY', ISBLANK: 'ISLEEG', ISERR: 'ISFOUT2', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 614fc7cf3..b95ff9d38 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'ZAOKR.DO.CAŁK', INTERVAL: 'INTERVAL', //FIXME IPMT: 'IPMT', + IRR: 'IRR', ISBINARY: 'ISBINARY', ISBLANK: 'CZY.PUSTA', ISERR: 'CZY.BŁ', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index acbbf65f4..6a6fb701a 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'INT', INTERVAL: 'INTERVAL', //FIXME IPMT: 'IPGTO', + IRR: 'TIR', ISBINARY: 'ISBINARY', ISBLANK: 'ÉCÉL.VAZIA', ISERR: 'ÉERRO', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 75c588249..70bafb967 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'ЦЕЛОЕ', INTERVAL: 'INTERVAL', //FIXME IPMT: 'ПРПЛТ', + IRR: 'ВСД', ISBINARY: 'ISBINARY', ISBLANK: 'ЕПУСТО', ISERR: 'ЕОШ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 68dd2a279..393c9b694 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'HELTAL', INTERVAL: 'INTERVAL', //FIXME IPMT: 'RBETALNING', + IRR: 'IR', ISBINARY: 'ISBINARY', ISBLANK: 'ÄRTOM', ISERR: 'ÄRF', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 47196b72e..b29cb9806 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -111,6 +111,7 @@ const dictionary: RawTranslationPackage = { INT: 'TAMSAYI', INTERVAL: 'INTERVAL', //FIXME IPMT: 'FAİZTUTARI', + IRR: 'İÇ_VERİM_ORANI', ISBINARY: 'ISBINARY', ISBLANK: 'EBOŞSA', ISERR: 'EHATA', diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index a7bf3d999..46d7bac25 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -281,6 +281,14 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp {argumentType: FunctionArgumentType.RANGE}, ], }, + 'IRR': { + method: 'irr', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 0.1}, + ], + returnNumberType: NumberType.NUMBER_PERCENT + }, } public pmt(ast: ProcedureAst, state: InterpreterState): InterpreterValue { @@ -750,6 +758,36 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp } ) } + + /** + * Calculates the internal rate of return for a series of cash flows. + * @param {ProcedureAst} ast - The AST node representing the function call. + * @param {InterpreterState} state - The interpreter state. + * @returns {InterpreterValue} The internal rate of return. + */ + public irr(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('IRR'), + (range: SimpleRangeValue, guess: number) => { + if (guess <= -1) { + return new CellError(ErrorType.VALUE) + } + + const vals = this.arithmeticHelper.manyToExactNumbers(range.valuesFromTopLeftCorner()) + if (vals instanceof CellError) { + return vals + } + + // Check for at least one positive and one negative value + const hasPositive = vals.some(val => val > 0) + const hasNegative = vals.some(val => val < 0) + if (!hasPositive || !hasNegative) { + return new CellError(ErrorType.NUM) + } + + return irrCore(vals, guess) + } + ) + } } function pmtCore(rate: number, periods: number, present: number, future: number, type: number): number { @@ -798,3 +836,60 @@ function npvCore(rate: number, args: number[]): number | CellError { } return acc } + +/** + * Calculates IRR using Newton-Raphson method. + * IRR is the rate r where: CF0 + CF1/(1+r) + CF2/(1+r)^2 + ... + CFn/(1+r)^n = 0 + */ +function irrCore(values: number[], guess: number): number | CellError { + const epsMax = 1e-10 + const iterMax = 50 + + let rate = guess + + for (let iter = 0; iter < iterMax; iter++) { + // Calculate NPV and its derivative at current rate + // NPV = sum of values[i] / (1+rate)^i for i = 0 to n-1 + // dNPV/dr = sum of -i * values[i] / (1+rate)^(i+1) for i = 0 to n-1 + let npv = 0 + let dnpv = 0 + + for (let i = 0; i < values.length; i++) { + const factor = Math.pow(1 + rate, i) + if (!isFinite(factor) || factor === 0) { + return new CellError(ErrorType.NUM) + } + npv += values[i] / factor + if (i > 0) { + dnpv -= i * values[i] / (factor * (1 + rate)) + } + } + + // Check for convergence + if (Math.abs(npv) < epsMax) { + return rate + } + + // Check if derivative is too small (avoid division by zero) + if (Math.abs(dnpv) < epsMax) { + return new CellError(ErrorType.NUM) + } + + // Newton-Raphson step + const newRate = rate - npv / dnpv + + // Check for convergence based on rate change + if (Math.abs(newRate - rate) < epsMax) { + return newRate + } + + rate = newRate + + // Check for invalid rate + if (!isFinite(rate) || rate <= -1) { + return new CellError(ErrorType.NUM) + } + } + + return new CellError(ErrorType.NUM) +} diff --git a/test/unit/interpreter/function-irr.spec.ts b/test/unit/interpreter/function-irr.spec.ts new file mode 100644 index 000000000..cee6e693b --- /dev/null +++ b/test/unit/interpreter/function-irr.spec.ts @@ -0,0 +1,464 @@ +import {ErrorType, HyperFormula} from '../../../src' +import {CellValueDetailedType} from '../../../src/Cell' +import {ErrorMessage} from '../../../src/error-message' +import {adr, detailedError} from '../testUtils' + +describe('Function IRR', () => { + const requiredFinancialPrecision = 6 // epsilon = 0.0000005 + + describe('argument validation', () => { + it('should return #NA! error with the wrong number of arguments', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR()', '=IRR(A2:A3, 0.1, 1)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + expect(engine.getCellValue(adr('B1'))).toEqualError(detailedError(ErrorType.NA, ErrorMessage.WrongArgNumber)) + }) + + it('should accept a single argument (values)', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should accept two arguments (values, guess)', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6, 0.15)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + }) + + describe('error handling', () => { + it('should return #NUM! if algorithm does not converge', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({100, 100, 100})'], + ['=IRR({-100, -100, -100})'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.NUM)) + }) + + it('should return #NUM! when derivative is too small', () => { + // At r=0 with cash flows {-0.5, 2, -1}: + // dnpv = -1*2/(1+0)^2 - 2*(-1)/(1+0)^3 = -2 + 2 = 0 + // npv = -0.5 + 2 - 1 = 0.5 != 0 + // So derivative is zero but NPV is not, Newton-Raphson cannot proceed + const engine = HyperFormula.buildFromArray([ + ['=IRR({-0.5, 2, -1}, 0)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + }) + + it('should return #NUM! when max iterations reached', () => { + // Cash flows that cause oscillation without convergence + const engine = HyperFormula.buildFromArray([ + ['=IRR({-1, 2, -1.0001}, 0.9)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + }) + + it('should return #NUM! when rate causes factor overflow', () => { + // Very large guess with multiple periods causes Math.pow(1+rate, i) to overflow to Infinity + const engine = HyperFormula.buildFromArray([ + ['=IRR({-1, 1, 1, 1, 1, 1, 1, 1, 1, 1}, 1e100)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + }) + + it('should return #NUM! when values do not contain at least one positive and one negative value', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({1, 2, 3})'], + ['=IRR({-1, -2, -3})'], + ['=IRR({0, 0, 0})'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + expect(engine.getCellValue(adr('A2'))).toEqualError(detailedError(ErrorType.NUM)) + expect(engine.getCellValue(adr('A3'))).toEqualError(detailedError(ErrorType.NUM)) + }) + + it('should propagate errors from values', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR(A2:A4)'], + [-1000], + ['=1/0'], + [500], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + }) + + it('should propagate errors from guess', () => { + const engine = HyperFormula.buildFromArray([ + [-1000, '=IRR(A1:A4, 1/0)'], + [100], + [200], + [800], + ]) + + expect(engine.getCellValue(adr('B1'))).toEqualError(detailedError(ErrorType.DIV_BY_ZERO)) + }) + + it('should return #VALUE! error when guess is not a number', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [800], + ['=IRR(A1:A4, "abc")'], + ]) + + expect(engine.getCellValue(adr('A5'))).toEqualError(detailedError(ErrorType.VALUE, ErrorMessage.NumberCoercion)) + }) + + it('should return #VALUE! error when guess is -1', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-100, 200, 300}, -1)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE)) + }) + + it('should return #VALUE! error when guess is less than -1', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-100, 200, 300}, -2)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.VALUE)) + }) + + it('should return #NUM! for empty range', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR(B1:B5)'], + ]) + + expect(engine.getCellValue(adr('A1'))).toEqualError(detailedError(ErrorType.NUM)) + }) + }) + + describe('value type', () => { + it('should set the value type to percent', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6)'], + ]) + + expect(engine.getCellValueDetailedType(adr('A7'))).toBe(CellValueDetailedType.NUMBER_PERCENT) + }) + }) + + describe('handling of text, logical, and empty values in ranges', () => { + it('should ignore text values in ranges', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + ['text'], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A7)'], + ]) + + expect(engine.getCellValue(adr('A8'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should ignore logical values in ranges', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [true], + [100], + [200], + [300], + [false], + [400], + [500], + ['=IRR(A1:A8)'], + ]) + + expect(engine.getCellValue(adr('A9'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should ignore empty cells in ranges', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [null], + [100], + [200], + [300], + [null], + [400], + [500], + ['=IRR(A1:A8)'], + ]) + + expect(engine.getCellValue(adr('A9'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + }) + + describe('basic calculations', () => { + it('should compute IRR for basic cash flows', () => { + const engine = HyperFormula.buildFromArray([ + [-70000], + [12000], + [15000], + [18000], + [21000], + ['=IRR(A1:A5)'], + ]) + + expect(engine.getCellValue(adr('A6'))).toBeCloseTo(-0.0212448, requiredFinancialPrecision) + }) + + it('should compute IRR for investment with positive return', () => { + const engine = HyperFormula.buildFromArray([ + [-70000], + [12000], + [15000], + [18000], + [21000], + [26000], + ['=IRR(A1:A6)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.0866309, requiredFinancialPrecision) + }) + + it('should compute IRR correctly for official example', () => { + // Example from https://support.microsoft.com/en-us/office/irr-function-64925eaa-9988-495b-b290-3ad0c163c1bc + const engine = HyperFormula.buildFromArray([ + [-70000], + [12000], + [15000], + [18000], + [21000], + [26000], + ['=IRR(A1:A5)'], // Four years return: ~-2.1% + ['=IRR(A1:A6)'], // Five years return: ~8.7% + ['=IRR(A1:A3, -10%)'], // Two years with guess: ~-44.4% + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(-0.0212448, requiredFinancialPrecision) + expect(engine.getCellValue(adr('A8'))).toBeCloseTo(0.0866309, requiredFinancialPrecision) + expect(engine.getCellValue(adr('A9'))).toBeCloseTo(-0.443507, requiredFinancialPrecision) + }) + + it('should work with inline arrays', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-100, 50, 50, 50})'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.233752, requiredFinancialPrecision) + }) + }) + + describe('guess parameter', () => { + it('should use default guess of 0.1 (10%) when not specified', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6)'], + ['=IRR(A1:A6, 0.1)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBe(engine.getCellValue(adr('A8'))) + }) + + it('should allow guess close to -1', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6, -0.9)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should accept guess value of 0', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6, 0)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should accept large positive guess values', () => { + const engine = HyperFormula.buildFromArray([ + [-100], + [200], + [300], + ['=IRR(A1:A3, 1)'], + ['=IRR(A1:A3, 2)'], + ]) + + // Should still find the correct IRR (or converge) + expect(typeof engine.getCellValue(adr('A4'))).toBe('number') + expect(typeof engine.getCellValue(adr('A5'))).toBe('number') + }) + }) + + describe('edge cases', () => { + it('should handle very small cash flows', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-0.0001, 0.00005, 0.00006})'], + ]) + + expect(typeof engine.getCellValue(adr('A1'))).toBe('number') + }) + + it('should handle very large cash flows', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-1000000000, 500000000, 600000000})'], + ]) + + expect(typeof engine.getCellValue(adr('A1'))).toBe('number') + }) + + it('should handle cash flows with zero values', () => { + const engine = HyperFormula.buildFromArray([ + ['=IRR({-1000, 0, 0, 0, 2000})'], + ]) + + expect(engine.getCellValue(adr('A1'))).toBeCloseTo(0.189207, 5) + }) + + it('should handle many periods', () => { + const engine = HyperFormula.buildFromArray([ + [-10000, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500], + [500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500], + ['=IRR(A1:K2)'], + ]) + + expect(typeof engine.getCellValue(adr('A3'))).toBe('number') + }) + }) + + describe('vertical and horizontal ranges', () => { + it('should work with vertical range', () => { + const engine = HyperFormula.buildFromArray([ + [-1000], + [100], + [200], + [300], + [400], + [500], + ['=IRR(A1:A6)'], + ]) + + expect(engine.getCellValue(adr('A7'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should work with horizontal range', () => { + const engine = HyperFormula.buildFromArray([ + [-1000, 100, 200, 300, 400, 500, '=IRR(A1:F1)'], + ]) + + expect(engine.getCellValue(adr('G1'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + + it('should work with 2D range (row by row)', () => { + const engine = HyperFormula.buildFromArray([ + [-1000, 100, 200], + [300, 400, 500], + ['=IRR(A1:C2)'], + ]) + + expect(engine.getCellValue(adr('A3'))).toBeCloseTo(0.120058, requiredFinancialPrecision) + }) + }) + + describe('cell references', () => { + it('should work with individual cell references', () => { + const engine = HyperFormula.buildFromArray([ + [-1000, 500, 600, '=IRR({A1, B1, C1})'], + ]) + + expect(typeof engine.getCellValue(adr('D1'))).toBe('number') + }) + + it('should work with named ranges', () => { + const engine = HyperFormula.buildFromArray([ + [-1000, 500, 600], + ]) + engine.addNamedExpression('cashflows', '=Sheet1!$A$1:$C$1') + engine.setCellContents(adr('A2'), [['=IRR(cashflows)']]) + + expect(typeof engine.getCellValue(adr('A2'))).toBe('number') + }) + }) + + describe('relationship with NPV', () => { + it('NPV at IRR rate should be approximately zero', () => { + const engine = HyperFormula.buildFromArray([ + [-70000], + [12000], + [15000], + [18000], + [21000], + [26000], + ['=IRR(A1:A6)'], + ['=NPV(A7, A1:A6)'], + ]) + + // NPV at IRR rate should be very close to zero + // Note: This depends on how NPV is implemented - it may need adjustment + const npvValue = engine.getCellValue(adr('A8')) + + expect(Math.abs(npvValue as number)).toBeLessThan(0.01) + }) + }) + + describe('scenarios with no solution or multiple solutions', () => { + it('should return either #NUM! or one of the solutions', () => { + // Non-conventional cash flows may have multiple IRRs + // Cash flow {-1000, 3000, -2500} has two valid IRRs: ~0.25 (25%) and ~1.0 (100%) + const engine = HyperFormula.buildFromArray([ + ['=IRR({-1000, 3000, -2500})'], + ]) + + const result = engine.getCellValue(adr('A1')) + + expect(result).toEqualError(detailedError(ErrorType.NUM)) + }) + }) +})