diff --git a/AGENTS.md b/AGENTS.md index ee5b0ee7d..99b25536c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ This section is maintained by the team. Whenever an AI agent makes a mistake wor - **Short title** — What the agent did wrong. What it should have done instead. --> -_No items yet._ +1. Often pull request descriptions becomes obsolete. Remember to update it as you work. ## Skills, MCPs, and other agent tools diff --git a/CHANGELOG.md b/CHANGELOG.md index a78788346..113bcb492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added a new function: `XIRR`. [#1701](https://github.com/handsontable/hyperformula/pull/1701) - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) - Added a `stringifyCurrency` config option that lets you plug in a custom currency formatter for the `TEXT` function. [#1145](https://github.com/handsontable/hyperformula/issues/1145) diff --git a/docs/guide/built-in-functions.md b/docs/guide/built-in-functions.md index cc535dbcf..d96f97e78 100644 --- a/docs/guide/built-in-functions.md +++ b/docs/guide/built-in-functions.md @@ -217,6 +217,7 @@ Total number of functions: **{{ $page.functionsCount }}** | TBILLEQ | Returns the bond-equivalent yield for a Treasury bill. | TBILLEQ(Settlement, Maturity, Discount) | | TBILLPRICE | Returns the price per $100 face value for a Treasury bill. | TBILLPRICE(Settlement, Maturity, Discount) | | TBILLYIELD | Returns the yield for a Treasury bill. | TBILLYIELD(Settlement, Maturity, Price) | +| XIRR | Returns the internal rate of return for a schedule of cash flows that is not necessarily periodic. | XIRR(Values, Dates[, Guess]) | | XNPV | Returns net present value. | XNPV(Rate, Payments, Dates) | ### Logical diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 71ce56b14..5a0b8ef00 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'WORKDAY.INTL', XLOOKUP: 'XVYHLEDAT', XNPV: 'XNPV', + XIRR: 'XIRR', XOR: 'XOR', YEAR: 'ROK', YEARFRAC: 'YEARFRAC', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index ea5a1e4ce..39a83b4fc 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'ARBEJDSDAG.INTL', XLOOKUP: 'XOPSLAG', XNPV: 'NETTO.NUTIDSVÆRDI', + XIRR: 'INTERN.RENTE', XOR: 'XELLER', YEAR: 'ÅR', YEARFRAC: 'ÅR.BRØK', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 24a8ec03b..4a3046ce8 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'ARBEITSTAG.INTL', XLOOKUP: 'XVERWEIS', XNPV: 'XKAPITALWERT', + XIRR: 'XINTZINSFUSS', XOR: 'XODER', YEAR: 'JAHR', YEARFRAC: 'BRTEILJAHRE', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index e878f897a..7e9130cd4 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -252,6 +252,7 @@ const dictionary: RawTranslationPackage = { WORKDAY: 'WORKDAY', 'WORKDAY.INTL': 'WORKDAY.INTL', XNPV: 'XNPV', + XIRR: 'XIRR', XOR: 'XOR', XLOOKUP: 'XLOOKUP', YEAR: 'YEAR', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index d36d14fd0..eb57d2d4f 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -251,6 +251,7 @@ export const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'DIA.LAB.INTL', XLOOKUP: 'BUSCARX', XNPV: 'VNA.NO.PER', + XIRR: 'TIR.NO.PER', XOR: 'XOR', YEAR: 'AÑO', YEARFRAC: 'FRAC.AÑO', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 5735278d8..655643e6a 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'TYÖPÄIVÄ.KANSVÄL', XLOOKUP: 'XHAKU', XNPV: 'NNA.JAKSOTON', + XIRR: 'SISÄINEN.KORKO.JAKSOTON', XOR: 'EHDOTON.TAI', YEAR: 'VUOSI', YEARFRAC: 'VUOSI.OSA', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 044c70715..a30d4a118 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'SERIE.JOUR.OUVRE.INTL', XLOOKUP: 'RECHERCHEX', XNPV: 'VAN.PAIEMENTS', + XIRR: 'TRI.PAIEMENTS', XOR: 'OUX', YEAR: 'ANNEE', YEARFRAC: 'FRACTION.ANNEE', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index 3a5119222..03b6b9dcf 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'KALK.MUNKANAP.INTL', XLOOKUP: 'XKERES', XNPV: 'XNJÉ', + XIRR: 'XBMR', XOR: 'XVAGY', YEAR: 'ÉV', YEARFRAC: 'TÖRTÉV', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index b07d9aad8..0a3f025bc 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -252,6 +252,7 @@ const dictionary: RawTranslationPackage = { WORKDAY: 'HARI.KERJA.SELESAI', 'WORKDAY.INTL': 'HARI.KERJA.SELESAI.INTL', XNPV: 'XNPV', + XIRR: 'XIRR', XOR: 'XATAU', XLOOKUP: 'XLOOKUP', YEAR: 'TAHUN', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 2716d3471..7dee12d8d 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'GIORNO.LAVORATIVO.INTL', XLOOKUP: 'CERCA.X', XNPV: 'VAN.X', + XIRR: 'TIR.X', XOR: 'XOR', YEAR: 'ANNO', YEARFRAC: 'FRAZIONE.ANNO', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index 80d9948f6..dcc51c4be 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'ARBEIDSDAG.INTL', XLOOKUP: 'XOPPSLAG', XNPV: 'XNNV', + XIRR: 'XIR', XOR: 'EKSKLUSIVELLER', YEAR: 'ÅR', YEARFRAC: 'ÅRDEL', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index 57d6fddb9..118f7b497 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'WERKDAG.INTL', XLOOKUP: 'X.ZOEKEN', XNPV: 'NHW2', + XIRR: 'IR.SCHEMA', XOR: 'EX.OF', YEAR: 'JAAR', YEARFRAC: 'JAAR.DEEL', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 251a35bba..262c2459e 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'DZIEŃ.ROBOCZY.NIESTAND', XLOOKUP: 'X.WYSZUKAJ', XNPV: 'XNPV', + XIRR: 'XIRR', XOR: 'XOR', YEAR: 'ROK', YEARFRAC: 'CZĘŚĆ.ROKU', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3195612f0..5d048e179 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'DIATRABALHO.INTL', XLOOKUP: 'PROCX', XNPV: 'XVPL', + XIRR: 'XTIR', XOR: 'OUEXCL', YEAR: 'ANO', YEARFRAC: 'FRAÇÃOANO', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index cfbf51e59..c42ef694c 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'РАБДЕНЬ.МЕЖД', XLOOKUP: 'ПРОСМОТРХ', XNPV: 'ЧИСТНЗ', + XIRR: 'ЧИСТВНДОХ', XOR: 'ИСКЛИЛИ', YEAR: 'ГОД', YEARFRAC: 'ДОЛЯГОДА', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index bce2c4cf8..90f9357f2 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'ARBETSDAGAR.INT', XLOOKUP: 'XLETAUPP', XNPV: 'XNUVÄRDE', + XIRR: 'XIRR', XOR: 'XOR', YEAR: 'ÅR', YEARFRAC: 'ÅRDEL', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index af2c25390..6e898ed36 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = { 'WORKDAY.INTL': 'İŞGÜNÜ.ULUSL', XLOOKUP: 'ÇAPRAZARA', XNPV: 'ANBD', + XIRR: 'AİÇVERİMORANI', XOR: 'ÖZELVEYA', YEAR: 'YIL', YEARFRAC: 'YILORAN', diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index 0071f44e7..c1f0c782a 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -10,6 +10,7 @@ import {InterpreterState} from '../InterpreterState' import { EmptyValue, getRawValue, + InternalScalarValue, InterpreterValue, isExtendedNumber, NumberType, @@ -289,6 +290,15 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ], returnNumberType: NumberType.NUMBER_PERCENT }, + 'XIRR': { + method: 'xirr', + parameters: [ + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.RANGE}, + {argumentType: FunctionArgumentType.NUMBER, defaultValue: 0.1}, + ], + returnNumberType: NumberType.NUMBER_PERCENT + }, } public pmt(ast: ProcedureAst, state: InterpreterState): InterpreterValue { @@ -788,6 +798,49 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp } ) } + + /** + * Calculates the internal rate of return for a schedule of cash flows that is not necessarily periodic. + * @param {ProcedureAst} ast - The AST node representing the function call. + * @param {InterpreterState} state - The interpreter state. + * @returns {InterpreterValue} The internal rate of return. + */ + public xirr(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('XIRR'), + (values: SimpleRangeValue, dates: SimpleRangeValue, guess: number) => { + if (guess <= -1) { + return new CellError(ErrorType.NUM) + } + + const cashFlows = sanitizeXirrRange(values.valuesFromTopLeftCorner()) + if (cashFlows instanceof CellError) { + return cashFlows + } + + const paymentDates = sanitizeXirrRange(dates.valuesFromTopLeftCorner()) + if (paymentDates instanceof CellError) { + return paymentDates + } + + if (cashFlows.length !== paymentDates.length) { + return new CellError(ErrorType.NUM, ErrorMessage.EqualLength) + } + + // A schedule needs at least two cash flows to define a rate of return. + if (cashFlows.length < 2) { + return new CellError(ErrorType.NA) + } + + const hasPositive = cashFlows.some(value => value > 0) + const hasNegative = cashFlows.some(value => value < 0) + if (!hasPositive || !hasNegative) { + return new CellError(ErrorType.NUM) + } + + return xirrCore(cashFlows, paymentDates, guess) + } + ) + } } function pmtCore(rate: number, periods: number, present: number, future: number, type: number): number { @@ -897,3 +950,110 @@ function irrCore(values: number[], guess: number): number | CellError { return new CellError(ErrorType.NUM) } + +/** + * Converts raw range values into an array of numbers for XIRR. + * + * Empty cells are treated as zeros, errors are propagated, and any non-numeric + * value (text or logical) results in a #VALUE! error. + * @param {InternalScalarValue[]} rawValues - The raw values taken from a range. + * @returns {number[] | CellError} The numeric values, or a propagated/coercion error. + */ +function sanitizeXirrRange(rawValues: InternalScalarValue[]): number[] | CellError { + const result: number[] = [] + for (const rawValue of rawValues) { + if (rawValue instanceof CellError) { + return rawValue + } else if (rawValue === EmptyValue) { + result.push(0) + } else if (isExtendedNumber(rawValue)) { + result.push(getRawValue(rawValue)) + } else { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberExpected) + } + } + return result +} + +/** + * Calculates XIRR using the Newton-Raphson method. + * + * XIRR is the rate r for which the net present value of the cash flows, discounted + * over the actual number of days between payments (assuming a 365-day year), equals zero: + * sum( values[i] / (1 + r)^((dates[i] - dates[0]) / 365) ) = 0 + * @param {number[]} values - The cash flow amounts. + * @param {number[]} dates - The payment dates as serial numbers, aligned with `values`. + * @param {number} guess - The initial estimate of the rate of return. + * @returns {number | CellError} The internal rate of return, or a #NUM! error. + */ +function xirrCore(values: number[], dates: number[], guess: number): number | CellError { + const epsMax = 1e-10 + const iterMax = 50 + + const startDate = Math.floor(dates[0]) + if (startDate < 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + + const dayOffsets: number[] = [] + for (let i = 0; i < dates.length; i++) { + const truncatedDate = Math.floor(dates[i]) + if (truncatedDate < startDate) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + dayOffsets.push(truncatedDate - startDate) + } + + let rate = guess + + for (let iter = 0; iter < iterMax; iter++) { + // Calculate XNPV and its derivative at the current rate. + let npv = 0 + let dnpv = 0 + + for (let i = 0; i < values.length; i++) { + const exponent = dayOffsets[i] / 365 + const base = 1 + rate + const factor = Math.pow(base, exponent) + if (!isFinite(factor) || factor === 0) { + return new CellError(ErrorType.NUM) + } + npv += values[i] / factor + dnpv -= exponent * values[i] / (factor * base) + } + + if (!isFinite(npv) || !isFinite(dnpv)) { + return new CellError(ErrorType.NUM) + } + + // Check for convergence. + if (Math.abs(npv) < epsMax) { + return rate + } + + // Check if the derivative is too small (avoid division by zero). + if (Math.abs(dnpv) < epsMax) { + return new CellError(ErrorType.NUM) + } + + // Newton-Raphson step. + let newRate = rate - npv / dnpv + if (!isFinite(newRate)) { + return new CellError(ErrorType.NUM) + } + + // Clamp: when Newton overshoots past -1, bisect between current rate and -1. + if (newRate <= -1) { + newRate = (rate - 1) / 2 + } + + // Check for convergence based on rate change. + if (Math.abs(newRate - rate) < epsMax) { + return newRate + } + + rate = newRate + } + + return new CellError(ErrorType.NUM) +}