Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/guide/built-in-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/csCZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'WORKDAY.INTL',
XLOOKUP: 'XVYHLEDAT',
XNPV: 'XNPV',
XIRR: 'XIRR',
XOR: 'XOR',
YEAR: 'ROK',
YEARFRAC: 'YEARFRAC',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/daDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/deDE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'ARBEITSTAG.INTL',
XLOOKUP: 'XVERWEIS',
XNPV: 'XKAPITALWERT',
XIRR: 'XINTZINSFUSS',
XOR: 'XODER',
YEAR: 'JAHR',
YEARFRAC: 'BRTEILJAHRE',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/enGB.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ const dictionary: RawTranslationPackage = {
WORKDAY: 'WORKDAY',
'WORKDAY.INTL': 'WORKDAY.INTL',
XNPV: 'XNPV',
XIRR: 'XIRR',
XOR: 'XOR',
XLOOKUP: 'XLOOKUP',
YEAR: 'YEAR',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/esES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/fiFI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/frFR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/huHU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/idID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/itIT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nbNO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'ARBEIDSDAG.INTL',
XLOOKUP: 'XOPPSLAG',
XNPV: 'XNNV',
XIRR: 'XIR',
XOR: 'EKSKLUSIVELLER',
YEAR: 'ÅR',
YEARFRAC: 'ÅRDEL',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/nlNL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/plPL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ptPT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'DIATRABALHO.INTL',
XLOOKUP: 'PROCX',
XNPV: 'XVPL',
XIRR: 'XTIR',
XOR: 'OUEXCL',
YEAR: 'ANO',
YEARFRAC: 'FRAÇÃOANO',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/ruRU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'РАБДЕНЬ.МЕЖД',
XLOOKUP: 'ПРОСМОТРХ',
XNPV: 'ЧИСТНЗ',
XIRR: 'ЧИСТВНДОХ',
XOR: 'ИСКЛИЛИ',
YEAR: 'ГОД',
YEARFRAC: 'ДОЛЯГОДА',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/svSE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ const dictionary: RawTranslationPackage = {
'WORKDAY.INTL': 'ARBETSDAGAR.INT',
XLOOKUP: 'XLETAUPP',
XNPV: 'XNUVÄRDE',
XIRR: 'XIRR',
XOR: 'XOR',
YEAR: 'ÅR',
YEARFRAC: 'ÅRDEL',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/languages/trTR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
160 changes: 160 additions & 0 deletions src/interpreter/plugin/FinancialPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {InterpreterState} from '../InterpreterState'
import {
EmptyValue,
getRawValue,
InternalScalarValue,
InterpreterValue,
isExtendedNumber,
NumberType,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Comment thread
sequba marked this conversation as resolved.
}

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 {
Expand Down Expand Up @@ -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)
}
Loading