From 030116ba7128a2f7d3fb94526a59febba97bfe1b Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 7 Oct 2026 22:12:56 +0100 Subject: [PATCH 1/9] Add a day-count helper and use it in YEARFRAC DateTimeHelper.dayCountByBasis(startDate, endDate, basis) returns the day count and the days in the year for day-count bases 0-4. Basis 0 applies toBasisUS to copies of the dates, basis 1 uses yearLengthForBasis, and basis 4 uses toBasisEU. YEARFRAC now returns dayCount / yearDays from the helper. Its results are unchanged. DAYS360 keeps its own days360Core and is not touched. Co-Authored-By: Claude Opus 5.5 --- src/DateTimeHelper.ts | 45 ++++++++++++++++++++++++ src/interpreter/plugin/DateTimePlugin.ts | 18 ++-------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/DateTimeHelper.ts b/src/DateTimeHelper.ts index aab02290c5..d2de40838e 100644 --- a/src/DateTimeHelper.ts +++ b/src/DateTimeHelper.ts @@ -211,6 +211,43 @@ export class DateTimeHelper { } } + /** + * Counts the days between two dates under a day-count basis, and returns the number of days in a year under the + * same basis. YEARFRAC and the securities functions (DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC, ACCRINTM) + * compute their year fraction as `dayCount / yearDays`. + * + * | `basis` | `dayCount` | `yearDays` | + * |---------|----------------------------------------------|-----------------------------| + * | 0 | US (NASD) 30/360, as adjusted by `toBasisUS` | 360 | + * | 1 | actual days | `yearLengthForBasis` | + * | 2 | actual days | 360 | + * | 3 | actual days | 365 | + * | 4 | European 30/360, as adjusted by `toBasisEU` | 360 | + * + * @param {number} startDate - date serial number, an integer not greater than `endDate` + * @param {number} endDate - date serial number, an integer + * @param {number} basis - day-count basis, an integer from 0 to 4 + */ + public dayCountByBasis(startDate: number, endDate: number, basis: number): { dayCount: number, yearDays: number } { + const start = this.numberToSimpleDate(startDate) + const end = this.numberToSimpleDate(endDate) + switch (basis) { + case 0: { + const [adjustedStart, adjustedEnd] = this.toBasisUS({...start}, {...end}) + return {dayCount: daysIn30DayMonths(adjustedStart, adjustedEnd), yearDays: 360} + } + case 1: + return {dayCount: endDate - startDate, yearDays: this.yearLengthForBasis(start, end)} + case 2: + return {dayCount: endDate - startDate, yearDays: 360} + case 3: + return {dayCount: endDate - startDate, yearDays: 365} + case 4: + return {dayCount: daysIn30DayMonths(toBasisEU(start), toBasisEU(end)), yearDays: 360} + } + throw new Error('Should not be reachable.') + } + private parseSingleFormat(dateString: string, dateFormat?: string, timeFormat?: string): Maybe { const dateTime = this.parseDateTime(dateString, dateFormat, timeFormat) if (instanceOfSimpleDate(dateTime)) { @@ -319,3 +356,11 @@ export function timeToNumber(time: SimpleTime): number { export function toBasisEU(date: SimpleDate): SimpleDate { return {year: date.year, month: date.month, day: Math.min(30, date.day)} } + +/** + * Counts the days between two dates as if every month had 30 days. The dates must already be adjusted to a 30/360 + * basis (`toBasisUS` or `toBasisEU`). + */ +function daysIn30DayMonths(start: SimpleDate, end: SimpleDate): number { + return 360 * (end.year - start.year) + 30 * (end.month - start.month) + end.day - start.day +} diff --git a/src/interpreter/plugin/DateTimePlugin.ts b/src/interpreter/plugin/DateTimePlugin.ts index fd1d3fad01..366c5a0db7 100644 --- a/src/interpreter/plugin/DateTimePlugin.ts +++ b/src/interpreter/plugin/DateTimePlugin.ts @@ -532,22 +532,8 @@ export class DateTimePlugin extends FunctionPlugin implements FunctionPluginType if (startDate > endDate) { [startDate, endDate] = [endDate, startDate] } - switch (mode) { - case 0: - return this.days360Core(startDate, endDate, false) / 360 - case 1: - return (endDate - startDate) / this.dateTimeHelper.yearLengthForBasis( - this.dateTimeHelper.numberToSimpleDate(startDate), - this.dateTimeHelper.numberToSimpleDate(endDate) - ) - case 2: - return (endDate - startDate) / 360 - case 3: - return (endDate - startDate) / 365 - case 4: - return this.days360Core(startDate, endDate, true) / 360 - } - throw new Error('Should not be reachable.') + const {dayCount, yearDays} = this.dateTimeHelper.dayCountByBasis(startDate, endDate, mode) + return dayCount / yearDays } ) } From 5de94b960ef53b9572c9376fdbcf9c30c356fd1a Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 7 Oct 2026 22:12:57 +0100 Subject: [PATCH 2/9] Add the ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions Six financial functions for discounted and interest-at-maturity securities, built on DateTimeHelper.dayCountByBasis. - Every argument is a SCALAR and is converted in the method, so booleans and the empty text give #VALUE! and invalid arguments are reported in Excel's order (dates, basis, then the amounts for the DISC-like functions; left to right for ACCRINTM). - Basis is truncated, but a negative basis is #NUM!, as in Excel. - Dates are truncated; dates outside the supported range give #NUM!. - The arithmetic follows the operation order that reproduces Excel's results to the 15th significant digit. - An empty or omitted ACCRINTM par is 1000. Excel rejects the 3-argument form; this is added to the list of differences. Adds catalogue entries, names in all language packs and a changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + docs/guide/list-of-differences.md | 1 + src/i18n/languages/csCZ.ts | 6 + src/i18n/languages/daDK.ts | 6 + src/i18n/languages/deDE.ts | 6 + src/i18n/languages/enGB.ts | 6 + src/i18n/languages/esES.ts | 6 + src/i18n/languages/fiFI.ts | 6 + src/i18n/languages/frFR.ts | 6 + src/i18n/languages/huHU.ts | 6 + src/i18n/languages/idID.ts | 6 + src/i18n/languages/itIT.ts | 6 + src/i18n/languages/nbNO.ts | 6 + src/i18n/languages/nlNL.ts | 6 + src/i18n/languages/plPL.ts | 6 + src/i18n/languages/ptPT.ts | 6 + src/i18n/languages/ruRU.ts | 6 + src/i18n/languages/svSE.ts | 6 + src/i18n/languages/trTR.ts | 6 + .../functionMetadata/categories/financial.ts | 42 +++ src/interpreter/plugin/FinancialPlugin.ts | 284 ++++++++++++++++++ 21 files changed, 433 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..985f015c97 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 new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. + ### Fixed - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index a109f82616..05bc580620 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -77,6 +77,7 @@ To remove the differences, create [custom implementations](custom-functions.md) | LCMP | =LCM(1000000, 1000001, 1000002, 1000003) | NUM | 5.00003E+23 | NUM | | TBILLPRICE | =TBILLPRICE(0, 180, 1.9) | 5.0000 | NUM | 5.0000 | | TBILLPRICE | =TBILLPRICE(0, 180, 2) | 0.0000 | NUM | 0.0000 | +| ACCRINTM | =ACCRINTM(DATE(2008,4,1), DATE(2008,6,15), 0.1) | 20.5556 | 20.5556 | Wrong number | | NPV | =NPV(1, TRUE(), 1) | 0.7500 | 0.5000 | 0.7500 | | NPV | =NPV(1,B1) where B1 = true | 0.5000 | 0.0000 | 0.0000 | | POISSON.DIST | =POISSON.DIST(-0.01, 0, FALSE()) | NUM | 1.0000 | NUM | diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index fea286083c..5ae7308e86 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'ACCRINTM', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'DEGREES', DELTA: 'DELTA', + DISC: 'DISC', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARFR', EDATE: 'EDATE', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'CELÁ.ČÁST', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'INTRATE', IPMT: 'PLATBA.ÚROK', IRR: 'MÍRA.VÝNOSNOSTI', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'SOUČIN', POWER: 'POWER', PPMT: 'PLATBA.ZÁKLAD', + PRICEDISC: 'PRICEDISC', PROPER: 'VELKÁ2', PV: 'SOUČHODNOTA', RADIANS: 'RADIANS', RAND: 'NÁHČÍSLO', RATE: 'ÚROKOVÁ.MÍRA', + RECEIVED: 'RECEIVED', REPLACE: 'NAHRADIT', REPT: 'OPAKOVAT', RIGHT: 'ZPRAVA', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XOR', YEAR: 'ROK', YEARFRAC: 'YEARFRAC', + YIELDDISC: 'YIELDDISC', ROMAN: 'ROMAN', ARABIC: 'ARABIC', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 9264551249..38df364820 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'PÅLØBRENTE.UDLØB', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ARCCOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'GRADER', DELTA: 'DELTA', + DISC: 'DISKONTO', DOLLARDE: 'KR.DECIMAL', DOLLARFR: 'KR.BRØK', EDATE: 'EDATO', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEKS', INT: 'HELTAL', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'RENTEFOD', IPMT: 'R.YDELSE', IRR: 'IA', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUKT', POWER: 'POTENS', PPMT: 'H.YDELSE', + PRICEDISC: 'KURS.DISKONTO', PROPER: 'STORT.FORBOGSTAV', PV: 'NV', RADIANS: 'RADIANER', RAND: 'SLUMP', RATE: 'RENTE', + RECEIVED: 'MODTAGET.VED.UDLØB', REPLACE: 'ERSTAT', REPT: 'GENTAG', RIGHT: 'HØJRE', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XELLER', YEAR: 'ÅR', YEARFRAC: 'ÅR.BRØK', + YIELDDISC: 'AFKAST.DISKONTO', ROMAN: 'ROMERTAL', ARABIC: 'ARABISK', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 4d3d39319b..87d3f1c4e5 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'AUFGELZINSF', ACOS: 'ARCCOS', ACOSH: 'ARCCOSHYP', ACOT: 'ARCCOTAN', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DEZIMAL', DEGREES: 'GRAD', DELTA: 'DELTA', + DISC: 'DISAGIO', DOLLARDE: 'NOTIERUNGDEZ', DOLLARFR: 'NOTIERUNGBRU', EDATE: 'EDATUM', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'GANZZAHL', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'ZINSSATZ', IPMT: 'ZINSZ', IRR: 'IKV', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUKT', POWER: 'POTENZ', PPMT: 'KAPZ', + PRICEDISC: 'KURSDISAGIO', PROPER: 'GROSS2', PV: 'BW', RADIANS: 'BOGENMASS', RAND: 'ZUFALLSZAHL', RATE: 'ZINS', + RECEIVED: 'AUSZAHLUNG', REPLACE: 'ERSETZEN', REPT: 'WIEDERHOLEN', RIGHT: 'RECHTS', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XODER', YEAR: 'JAHR', YEARFRAC: 'BRTEILJAHRE', + YIELDDISC: 'RENDITEDIS', ROMAN: 'RÖMISCH', ARABIC: 'ARABISCH', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index d271b732cd..14fc955a11 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'ACCRINTM', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -97,6 +98,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'DEGREES', DELTA: 'DELTA', + DISC: 'DISC', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARFR', EDATE: 'EDATE', @@ -125,6 +127,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'INT', INTERVAL: 'INTERVAL', + INTRATE: 'INTRATE', IPMT: 'IPMT', IRR: 'IRR', ISBINARY: 'ISBINARY', @@ -185,11 +188,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUCT', POWER: 'POWER', PPMT: 'PPMT', + PRICEDISC: 'PRICEDISC', PROPER: 'PROPER', PV: 'PV', RADIANS: 'RADIANS', RAND: 'RAND', RATE: 'RATE', + RECEIVED: 'RECEIVED', REPLACE: 'REPLACE', REPT: 'REPT', RIGHT: 'RIGHT', @@ -261,6 +266,7 @@ const dictionary: RawTranslationPackage = { XLOOKUP: 'XLOOKUP', YEAR: 'YEAR', YEARFRAC: 'YEARFRAC', + YIELDDISC: 'YIELDDISC', 'HF.ADD': 'HF.ADD', 'HF.CONCAT': 'HF.CONCAT', 'HF.DIVIDE': 'HF.DIVIDE', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 9c6b817b98..c9fddad644 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -25,6 +25,7 @@ export const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'INT.ACUM.V', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ export const dictionary: RawTranslationPackage = { DECIMAL: 'CONV.DECIMAL', DEGREES: 'GRADOS', DELTA: 'DELTA', + DISC: 'TASA.DESC', DOLLARDE: 'MONEDA.DEC', DOLLARFR: 'MONEDA.FRAC', EDATE: 'FECHA.MES', @@ -124,6 +126,7 @@ export const dictionary: RawTranslationPackage = { INDEX: 'INDICE', INT: 'ENTERO', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'TASA.INT', IPMT: 'PAGOINT', IRR: 'TIR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ export const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUCTO', POWER: 'POTENCIA', PPMT: 'PAGOPRIN', + PRICEDISC: 'PRECIO.DESCUENTO', PROPER: 'NOMPROPIO', PV: 'VA', RADIANS: 'RADIANES', RAND: 'ALEATORIO', RATE: 'TASA', + RECEIVED: 'CANTIDAD.RECIBIDA', REPLACE: 'REEMPLAZAR', REPT: 'REPETIR', RIGHT: 'DERECHA', @@ -259,6 +264,7 @@ export const dictionary: RawTranslationPackage = { XOR: 'XOR', YEAR: 'AÑO', YEARFRAC: 'FRAC.AÑO', + YIELDDISC: 'RENDTO.DESC', ROMAN: 'NUMERO.ROMANO', ARABIC: 'ARABIC', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 99ff05708f..92ae19c978 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ITSEISARVO', + ACCRINTM: 'KERTYNYT.KORKO.LOPUSSA', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DESIMAALI', DEGREES: 'ASTEET', DELTA: 'SAMA.ARVO', + DISC: 'DISKONTTOKORKO', DOLLARDE: 'VALUUTTA.DES', DOLLARFR: 'VALUUTTA.MURTO', EDATE: 'PÄIVÄ.KUUKAUSI', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEKSI', INT: 'KOKONAISLUKU', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'KORKO.ARVOPAPERI', IPMT: 'IPMT', IRR: 'SISÄINEN.KORKO', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'TULO', POWER: 'POTENSSI', PPMT: 'PPMT', + PRICEDISC: 'HINTA.DISK', PROPER: 'ERISNIMI', PV: 'NA', RADIANS: 'RADIAANIT', RAND: 'SATUNNAISLUKU', RATE: 'KORKO', + RECEIVED: 'SAATU.HINTA', REPLACE: 'KORVAA', REPT: 'TOISTA', RIGHT: 'OIKEA', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'EHDOTON.TAI', YEAR: 'VUOSI', YEARFRAC: 'VUOSI.OSA', + YIELDDISC: 'TUOTTO.DISK', ROMAN: 'ROMAN', ARABIC: 'ARABIA', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index f7b9be45f7..46ddc3341a 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'INTERET.ACC.MAT', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'DEGRES', DELTA: 'DELTA', + DISC: 'TAUX.ESCOMPTE', DOLLARDE: 'PRIX.DEC', DOLLARFR: 'PRIX.FRAC', EDATE: 'MOIS.DECALER', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'ENT', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'TAUX.INTERET', IPMT: 'INTPER', IRR: 'TRI', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUIT', POWER: 'PUISSANCE', PPMT: 'PRINCPER', + PRICEDISC: 'VALEUR.ENCAISSEMENT', PROPER: 'NOMPROPRE', PV: 'VA', RADIANS: 'RADIANS', RAND: 'ALEA', RATE: 'TAUX', + RECEIVED: 'VALEUR.NOMINALE', REPLACE: 'REMPLACER', REPT: 'REPT', RIGHT: 'DROITE', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'OUX', YEAR: 'ANNEE', YEARFRAC: 'FRACTION.ANNEE', + YIELDDISC: 'RENDEMENT.SIMPLE', ROMAN: 'ROMAIN', ARABIC: 'ARABE', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index e40219553a..be10141f63 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'LEJÁRATI.KAMAT', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ARCCOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'TIZEDES', DEGREES: 'FOK', DELTA: 'DELTA', + DISC: 'LESZÁM', DOLLARDE: 'FORINT.DEC', DOLLARFR: 'FORINT.TÖRT', EDATE: 'KALK.DÁTUM', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'INT', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'KAMATRÁTA', IPMT: 'RRÉSZLET', IRR: 'BMR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'SZORZAT', POWER: 'HATVÁNY', PPMT: 'PRÉSZLET', + PRICEDISC: 'ÁR.LESZÁM', PROPER: 'TNÉV', PV: 'MÉ', RADIANS: 'RADIÁN', RAND: 'VÉL', RATE: 'RÁTA', + RECEIVED: 'KAPOTT', REPLACE: 'CSERE', REPT: 'SOKSZOR', RIGHT: 'JOBB', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XVAGY', YEAR: 'ÉV', YEARFRAC: 'TÖRTÉV', + YIELDDISC: 'HOZAM.LESZÁM', ROMAN: 'RÓMAI', ARABIC: 'ARAB', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 719cb11db6..362748cb91 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'BATASAN.MATRIKS', ARRAYFORMULA: 'RUMUS.MATRIKS', ABS: 'ABS', + ACCRINTM: 'ACCRINTM', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -97,6 +98,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DESIMAL', DEGREES: 'DERAJAT', DELTA: 'DELTA', + DISC: 'DISC', DOLLARDE: 'DOLAR.DES', DOLLARFR: 'DOLAR.PEC', EDATE: 'EDATE', @@ -125,6 +127,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEKS', INT: 'BULAT.BAWAH', INTERVAL: 'INTERVAL', + INTRATE: 'INTRATE', IPMT: 'IPMT', IRR: 'IRR', ISBINARY: 'ADALAH.BINER', @@ -185,11 +188,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUK', POWER: 'PANGKAT', PPMT: 'PPMT', + PRICEDISC: 'PRICEDISC', PROPER: 'HURUF.AWAL.BESAR', PV: 'PV', RADIANS: 'RADIAN', RAND: 'ACAK', RATE: 'SUKU.BUNGA', + RECEIVED: 'RECEIVED', REPLACE: 'GANTI', REPT: 'ULANG', RIGHT: 'KANAN', @@ -261,6 +266,7 @@ const dictionary: RawTranslationPackage = { XLOOKUP: 'XLOOKUP', YEAR: 'TAHUN', YEARFRAC: 'FRAC.TAHUN', + YIELDDISC: 'YIELDDISC', 'HF.ADD': 'HF.ADD', 'HF.CONCAT': 'HF.CONCAT', 'HF.DIVIDE': 'HF.DIVIDE', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 1a494628f8..661d93f61d 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ASS', + ACCRINTM: 'INT.MATURATO.SCAD', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ARCCOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMALE', DEGREES: 'GRADI', DELTA: 'DELTA', + DISC: 'TASSO.SCONTO', DOLLARDE: 'VALUTA.DEC', DOLLARFR: 'VALUTA.FRAZ', EDATE: 'DATA.MESE', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDICE', INT: 'INT', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'TASSO.INT', IPMT: 'INTERESSI', IRR: 'TIR.COST', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODOTTO', POWER: 'POTENZA', PPMT: 'P.RATA', + PRICEDISC: 'PREZZO.SCONT', PROPER: 'MAIUSC.INIZ', PV: 'VA', RADIANS: 'RADIANTI', RAND: 'CASUALE', RATE: 'TASSO', + RECEIVED: 'RICEV.SCAD', REPLACE: 'RIMPIAZZA', REPT: 'RIPETI', RIGHT: 'DESTRA', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XOR', YEAR: 'ANNO', YEARFRAC: 'FRAZIONE.ANNO', + YIELDDISC: 'REND.TITOLI.SCONT', ROMAN: 'ROMANO', ARABIC: 'ARABO', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index ff15415a98..f3049f460a 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'PÅLØPT.FORFALLSRENTE', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DESIMAL', DEGREES: 'GRADER', DELTA: 'DELTA', + DISC: 'DISKONTERT', DOLLARDE: 'DOLLARDE', DOLLARFR: 'DOLLARBR', EDATE: 'DAG.ETTER', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEKS', INT: 'HELTALL', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'RENTESATS', IPMT: 'RAVDRAG', IRR: 'IR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUKT', POWER: 'OPPHØYD.I', PPMT: 'AMORT', + PRICEDISC: 'PRIS.DISKONTERT', PROPER: 'STOR.FORBOKSTAV', PV: 'NÅVERDI', RADIANS: 'RADIANER', RAND: 'TILFELDIG', RATE: 'RENTE', + RECEIVED: 'MOTTATT.AVKAST', REPLACE: 'ERSTATT', REPT: 'GJENTA', RIGHT: 'HØYRE', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'EKSKLUSIVELLER', YEAR: 'ÅR', YEARFRAC: 'ÅRDEL', + YIELDDISC: 'AVKAST.DISKONTERT', ROMAN: 'ROMERTALL', ARABIC: 'ARABISK', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index c491e2f44b..fa876c35df 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'SAMENG.RENTE.V', ACOS: 'BOOGCOS', ACOSH: 'BOOGCOSH', ACOT: 'BOOGCOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAAL', DEGREES: 'GRADEN', DELTA: 'DELTA', + DISC: 'DISCONTO', DOLLARDE: 'EURO.DE', DOLLARFR: 'EURO.BR', EDATE: 'ZELFDE.DAG', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'INTEGER', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'RENTEPERCENTAGE', IPMT: 'IBET', IRR: 'IR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUCT', POWER: 'MACHT', PPMT: 'PBET', + PRICEDISC: 'PRIJS.DISCONTO', PROPER: 'BEGINLETTERS', PV: 'HW', RADIANS: 'RADIALEN', RAND: 'ASELECT', RATE: 'RENTE', + RECEIVED: 'OPBRENGST', REPLACE: 'VERVANGEN', REPT: 'HERHALING', RIGHT: 'RECHTS', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'EX.OF', YEAR: 'JAAR', YEARFRAC: 'JAAR.DEEL', + YIELDDISC: 'REND.DISCONTO', ROMAN: 'ROMEINS', ARABIC: 'ARABISCH', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 6fa4f016b4..73942b01c9 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'WART.BEZWGL', + ACCRINTM: 'NAL.ODS.WYKUP', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DZIESIĘTNA', DEGREES: 'STOPNIE', DELTA: 'DELTA', + DISC: 'STOPA.DYSK', DOLLARDE: 'CENA.DZIES', DOLLARFR: 'CENA.UŁAM', EDATE: 'NR.SER.DATY', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEKS', INT: 'ZAOKR.DO.CAŁK', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'STOPA.PROC', IPMT: 'IPMT', IRR: 'IRR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'ILOCZYN', POWER: 'POTĘGA', PPMT: 'PPMT', + PRICEDISC: 'CENA.DYSK', PROPER: 'Z.WIELKIEJ.LITERY', PV: 'PV', RADIANS: 'RADIANY', RAND: 'LOSUJ', RATE: 'RATE', + RECEIVED: 'KWOTA.WYKUP', REPLACE: 'ZASTĄP', REPT: 'POWT', RIGHT: 'PRAWY', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XOR', YEAR: 'ROK', YEARFRAC: 'CZĘŚĆ.ROKU', + YIELDDISC: 'RENT.DYSK', ROMAN: 'RZYMSKIE', ARABIC: 'ARABSKA', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 6c1e68c328..3928f321a1 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'JUROSACUMV', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'GRAUS', DELTA: 'DELTA', + DISC: 'DESC', DOLLARDE: 'MOEDADEC', DOLLARFR: 'MOEDAFRA', EDATE: 'DATAM', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'ÍNDICE', INT: 'INT', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'TAXAJUROS', IPMT: 'IPGTO', IRR: 'TIR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'MULT', POWER: 'POTÊNCIA', PPMT: 'PPGTO', + PRICEDISC: 'PREÇODESC', PROPER: 'PRI.MAIÚSCULA', PV: 'VP', RADIANS: 'RADIANOS', RAND: 'ALEATÓRIO', RATE: 'TAXA', + RECEIVED: 'RECEBER', REPLACE: 'MUDAR', REPT: 'REPT', RIGHT: 'DIREITA', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'OUEXCL', YEAR: 'ANO', YEARFRAC: 'FRAÇÃOANO', + YIELDDISC: 'LUCRODESC', ROMAN: 'ROMANO', ARABIC: 'CARDINAL', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 6b35c1ce2f..17f8d6e854 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'НАКОПДОХОДПОГАШ', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'ДЕС', DEGREES: 'ГРАДУСЫ', DELTA: 'ДЕЛЬТА', + DISC: 'СКИДКА', DOLLARDE: 'РУБЛЬ.ДЕС', DOLLARFR: 'РУБЛЬ.ДРОБЬ', EDATE: 'ДАТАМЕС', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'ИНДЕКС', INT: 'ЦЕЛОЕ', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'ИНОРМА', IPMT: 'ПРПЛТ', IRR: 'ВСД', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'ПРОИЗВЕД', POWER: 'СТЕПЕНЬ', PPMT: 'ОСПЛТ', + PRICEDISC: 'ЦЕНАСКИДКА', PROPER: 'ПРОПНАЧ', PV: 'ПС', RADIANS: 'РАДИАНЫ', RAND: 'СЛЧИС', RATE: 'СТАВКА', + RECEIVED: 'ПОЛУЧЕНО', REPLACE: 'ЗАМЕНИТЬ', REPT: 'ПОВТОР', RIGHT: 'ПРАВСИМВ', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'ИСКЛИЛИ', YEAR: 'ГОД', YEARFRAC: 'ДОЛЯГОДА', + YIELDDISC: 'ДОХОДСКИДКА', ROMAN: 'РИМСКОЕ', ARABIC: 'АРАБСКОЕ', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 5f87580087..21ac96033f 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'ABS', + ACCRINTM: 'UPPLOBLRÄNTA', ACOS: 'ARCCOS', ACOSH: 'ARCCOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'DECIMAL', DEGREES: 'GRADER', DELTA: 'DELTA', + DISC: 'DISK', DOLLARDE: 'DECTAL', DOLLARFR: 'BRÅK', EDATE: 'EDATUM', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'INDEX', INT: 'HELTAL', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'ÅRSRÄNTA', IPMT: 'RBETALNING', IRR: 'IR', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'PRODUKT', POWER: 'UPPHÖJT.TILL', PPMT: 'AMORT', + PRICEDISC: 'PRISDISK', PROPER: 'INITIAL', PV: 'NUVÄRDE', RADIANS: 'RADIANER', RAND: 'SLUMP', RATE: 'RÄNTA', + RECEIVED: 'BELOPP', REPLACE: 'ERSÄTT', REPT: 'REP', RIGHT: 'HÖGER', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'XOR', YEAR: 'ÅR', YEARFRAC: 'ÅRDEL', + YIELDDISC: 'NOMAVKDISK', ROMAN: 'ROMERSK', ARABIC: 'ARABIC', 'HF.ADD': 'HF.ADD', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 93c2176d37..dc5a53cbb2 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -25,6 +25,7 @@ const dictionary: RawTranslationPackage = { 'ARRAY_CONSTRAIN': 'ARRAY_CONSTRAIN', ARRAYFORMULA: 'ARRAYFORMULA', ABS: 'MUTLAK', + ACCRINTM: 'GERÇEKFAİZV', ACOS: 'ACOS', ACOSH: 'ACOSH', ACOT: 'ACOT', @@ -96,6 +97,7 @@ const dictionary: RawTranslationPackage = { DECIMAL: 'ONDALIK', DEGREES: 'DERECE', DELTA: 'DELTA', + DISC: 'İNDİRİM', DOLLARDE: 'LİRAON', DOLLARFR: 'LİRAKES', EDATE: 'SERİTARİH', @@ -124,6 +126,7 @@ const dictionary: RawTranslationPackage = { INDEX: 'İNDİS', INT: 'TAMSAYI', INTERVAL: 'INTERVAL', //FIXME + INTRATE: 'FAİZORANI', IPMT: 'FAİZTUTARI', IRR: 'İÇ_VERİM_ORANI', ISBINARY: 'ISBINARY', @@ -184,11 +187,13 @@ const dictionary: RawTranslationPackage = { PRODUCT: 'ÇARPIM', POWER: 'KUVVET', PPMT: 'ANA_PARA_ÖDEMESİ', + PRICEDISC: 'DEĞERİND', PROPER: 'YAZIM.DÜZENİ', PV: 'BD', RADIANS: 'RADYAN', RAND: 'S_SAYI_ÜRET', RATE: 'FAİZ_ORANI', + RECEIVED: 'GETİRİ', REPLACE: 'DEĞİŞTİR', REPT: 'YİNELE', RIGHT: 'SAĞ', @@ -259,6 +264,7 @@ const dictionary: RawTranslationPackage = { XOR: 'ÖZELVEYA', YEAR: 'YIL', YEARFRAC: 'YILORAN', + YIELDDISC: 'ÖDEMEİND', ROMAN: 'ROMEN', ARABIC: 'ARAPÇA', 'HF.ADD': 'HF.ADD', diff --git a/src/interpreter/functionMetadata/categories/financial.ts b/src/interpreter/functionMetadata/categories/financial.ts index f673c11953..febe1ab7c7 100644 --- a/src/interpreter/functionMetadata/categories/financial.ts +++ b/src/interpreter/functionMetadata/categories/financial.ts @@ -10,6 +10,13 @@ import {FunctionDoc} from '../FunctionDescription' * truth for the function metadata API, and `docs/guide/built-in-functions.md` is generated from it. */ export const FINANCIAL_DOCS: Record = { + ACCRINTM: { + category: 'Financial', + shortDescription: 'Returns the accrued interest of a security that pays interest at maturity.', + parameters: [{name: 'issue', description: 'The issue date, as a date serial number, truncated to an integer.'}, {name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer; must not be earlier than `issue` (equal dates give 0).'}, {name: 'rate', description: 'The annual coupon rate; must be greater than 0.'}, {name: 'par', description: 'The par value; must be greater than 0. If the argument is omitted or empty, 1000 is used.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=ACCRINTM(DATE(2008,4,1), DATE(2008,6,15), 0.1, 1000, 3)', '=ACCRINTM(A1, B1, 0.05)'], + }, CUMIPMT: { category: 'Financial', shortDescription: 'Returns the cumulative interest paid on a loan between a start period and an end period.', @@ -38,6 +45,13 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=DDB(10000, 1000, 6, 2)', '=DDB(10000, 1000, 6, 1, 1.5)'], }, + DISC: { + category: 'Financial', + shortDescription: 'Returns the discount rate of a security.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'pr', description: 'The price per $100 face value; must be greater than 0.'}, {name: 'redemption', description: 'The redemption value per $100 face value; must be greater than 0.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=DISC(DATE(2018,7,1), DATE(2048,1,1), 97.975, 100, 1)', '=DISC(A1, B1, 99, 100)'], + }, DOLLARDE: { category: 'Financial', shortDescription: 'Converts a price entered with a special notation to a price displayed as a decimal number.', @@ -73,6 +87,13 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=FVSCHEDULE(1000, A1:A3)', '=FVSCHEDULE(5000, B2:B5)'], }, + INTRATE: { + category: 'Financial', + shortDescription: 'Returns the interest rate of a fully invested security.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'investment', description: 'The amount invested; must be greater than 0.'}, {name: 'redemption', description: 'The amount received at `maturity`; must be greater than 0.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=INTRATE(DATE(2008,2,15), DATE(2008,5,15), 1000000, 1014420, 2)', '=INTRATE(A1, B1, 1000, 1050)'], + }, IPMT: { category: 'Financial', shortDescription: 'Returns the interest portion of a given loan payment in a given payment period.', @@ -143,6 +164,13 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=PPMT(0.05/12, 1, 60, 20000)', '=PPMT(0.04/12, 12, 360, 200000, 0, 1)'], }, + PRICEDISC: { + category: 'Financial', + shortDescription: 'Returns the price per $100 face value of a discounted security.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'discount', description: 'The discount rate; must be greater than 0. A large `discount` gives a zero or negative price.'}, {name: 'redemption', description: 'The redemption value per $100 face value; must be greater than 0.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=PRICEDISC(DATE(2008,2,16), DATE(2008,3,1), 0.0525, 100, 2)', '=PRICEDISC(A1, B1, 0.05, 100)'], + }, PV: { category: 'Financial', shortDescription: 'Returns the present value of an investment.', @@ -157,6 +185,13 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=RATE(60, -100, 5000)', '=RATE(360, -1000, 200000, 0, 0, 0.05)'], }, + RECEIVED: { + category: 'Financial', + shortDescription: 'Returns the amount received at maturity for a fully invested security.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'investment', description: 'The amount invested; must be greater than 0.'}, {name: 'discount', description: 'The discount rate; must be greater than 0. If `discount` multiplied by the year fraction from `settlement` to `maturity` is 1 or more, the result is #NUM!.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=RECEIVED(DATE(2008,2,15), DATE(2008,5,15), 1000000, 0.0575, 2)', '=RECEIVED(A1, B1, 1000, 0.05)'], + }, RRI: { category: 'Financial', shortDescription: 'Returns an equivalent interest rate for the growth of an investment.', @@ -213,4 +248,11 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=XIRR(A1:A4, B1:B4)', '=XIRR(A1:A5, B1:B5, 0.1)'], }, + YIELDDISC: { + category: 'Financial', + shortDescription: 'Returns the annual yield of a discounted security.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'pr', description: 'The price per $100 face value; must be greater than 0.'}, {name: 'redemption', description: 'The redemption value per $100 face value; must be greater than 0.'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=YIELDDISC(DATE(2008,2,16), DATE(2008,3,1), 99.795, 100, 2)', '=YIELDDISC(A1, B1, 99, 100)'], + }, } diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index c1f0c782a8..1a0c28b3dd 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -240,6 +240,66 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ], returnNumberType: NumberType.NUMBER_PERCENT }, + 'DISC': { + method: 'disc', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'INTRATE': { + method: 'intrate', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'PRICEDISC': { + method: 'pricedisc', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'RECEIVED': { + method: 'received', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'YIELDDISC': { + method: 'yielddisc', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'ACCRINTM': { + method: 'accrintm', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 1000, emptyAsDefault: true}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, 'FVSCHEDULE': { method: 'fvschedule', parameters: [ @@ -649,6 +709,128 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ) } + /** + * Corresponds to DISC(settlement, maturity, pr, redemption, [basis]). + * + * Returns the discount rate of a security. + * + * @param ast + * @param state + */ + public disc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.discountedSecurity(ast, state, 'DISC', + (dayCount: number, yearDays: number, price: number, redemption: number) => (1 - price / redemption) * (yearDays / dayCount) + ) + } + + /** + * Corresponds to INTRATE(settlement, maturity, investment, redemption, [basis]). + * + * Returns the interest rate of a fully invested security. + * + * @param ast + * @param state + */ + public intrate(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.discountedSecurity(ast, state, 'INTRATE', + (dayCount: number, yearDays: number, investment: number, redemption: number) => (redemption - investment) / investment * (yearDays / dayCount) + ) + } + + /** + * Corresponds to PRICEDISC(settlement, maturity, discount, redemption, [basis]). + * + * Returns the price per $100 face value of a discounted security. + * + * @param ast + * @param state + */ + public pricedisc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.discountedSecurity(ast, state, 'PRICEDISC', + (dayCount: number, yearDays: number, discount: number, redemption: number) => redemption - discount * redemption * (dayCount / yearDays) + ) + } + + /** + * Corresponds to RECEIVED(settlement, maturity, investment, discount, [basis]). + * + * Returns the amount received at maturity for a fully invested security. + * Returns #NUM! when the discount for the whole period reaches the investment, that is when + * `discount * dayCount / yearDays` is 1 or more. + * + * @param ast + * @param state + */ + public received(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.discountedSecurity(ast, state, 'RECEIVED', + (dayCount: number, yearDays: number, investment: number, discount: number) => { + const denominator = 1 - discount * (dayCount / yearDays) + if (denominator <= 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + return investment / denominator + } + ) + } + + /** + * Corresponds to YIELDDISC(settlement, maturity, pr, redemption, [basis]). + * + * Returns the annual yield of a discounted security. + * + * @param ast + * @param state + */ + public yielddisc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.discountedSecurity(ast, state, 'YIELDDISC', + (dayCount: number, yearDays: number, price: number, redemption: number) => (redemption - price) / price * (yearDays / dayCount) + ) + } + + /** + * Corresponds to ACCRINTM(issue, settlement, rate, [par], [basis]). + * + * Returns the accrued interest of a security that pays interest at maturity. An empty or omitted `par` is 1000. + * Arguments are validated from left to right, as in Excel. `issue` equal to `settlement` gives 0. + * + * @param ast + * @param state + */ + public accrintm(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata('ACCRINTM'), + (issueArg: InternalScalarValue, settlementArg: InternalScalarValue, rateArg: InternalScalarValue, parArg: InternalScalarValue, basisArg: InternalScalarValue) => { + const issue = this.coerceToSecurityDate(issueArg) + if (issue instanceof CellError) { + return issue + } + const settlement = this.coerceToSecurityDate(settlementArg) + if (settlement instanceof CellError) { + return settlement + } + const rate = this.strictNumber(rateArg) + if (rate instanceof CellError) { + return rate + } + const par = this.strictNumber(parArg) + if (par instanceof CellError) { + return par + } + const basis = this.coerceToDayCountBasis(basisArg) + if (basis instanceof CellError) { + return basis + } + if (issue > settlement) { + return new CellError(ErrorType.NUM, ErrorMessage.StartEndDate) + } + if (rate <= 0 || par <= 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + const {dayCount, yearDays} = this.dateTimeHelper.dayCountByBasis(issue, settlement, basis) + return par * rate * (dayCount / yearDays) + } + ) + } + public fvschedule(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('FVSCHEDULE'), (value: number, ratios: SimpleRangeValue) => { @@ -841,6 +1023,108 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp } ) } + + /** + * Runs DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC, which share the arguments + * (settlement, maturity, first amount, second amount, [basis]). + * + * Validates in Excel's order: settlement and maturity, then basis, then the two amounts (errors and #VALUE!), + * then settlement before maturity and both amounts positive (#NUM!). Passes the day count and the days in the year + * between settlement and maturity to `calculate`. + * + * @param {ProcedureAst} ast - the function's AST + * @param {InterpreterState} state - the interpreter state + * @param {string} functionName - the function's id, used to look up its metadata + * @param {Function} calculate - computes the result from the day count, the days in the year and the two amounts + */ + private discountedSecurity( + ast: ProcedureAst, + state: InterpreterState, + functionName: string, + calculate: (dayCount: number, yearDays: number, firstAmount: number, secondAmount: number) => number | CellError, + ): InterpreterValue { + return this.runFunction(ast.args, state, this.metadata(functionName), + (settlementArg: InternalScalarValue, maturityArg: InternalScalarValue, firstAmountArg: InternalScalarValue, secondAmountArg: InternalScalarValue, basisArg: InternalScalarValue) => { + const settlement = this.coerceToSecurityDate(settlementArg) + if (settlement instanceof CellError) { + return settlement + } + const maturity = this.coerceToSecurityDate(maturityArg) + if (maturity instanceof CellError) { + return maturity + } + const basis = this.coerceToDayCountBasis(basisArg) + if (basis instanceof CellError) { + return basis + } + const firstAmount = this.strictNumber(firstAmountArg) + if (firstAmount instanceof CellError) { + return firstAmount + } + const secondAmount = this.strictNumber(secondAmountArg) + if (secondAmount instanceof CellError) { + return secondAmount + } + if (settlement >= maturity) { + return new CellError(ErrorType.NUM, ErrorMessage.StartEndDate) + } + if (firstAmount <= 0 || secondAmount <= 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + const {dayCount, yearDays} = this.dateTimeHelper.dayCountByBasis(settlement, maturity, basis) + return calculate(dayCount, yearDays, firstAmount, secondAmount) + } + ) + } + + /** + * Converts a date argument of a securities function: a number as `strictNumber` accepts it, truncated to an integer. + * A date outside the supported range is #NUM!. + */ + private coerceToSecurityDate(value: InternalScalarValue): number | CellError { + const dateNumber = this.strictNumber(value) + if (dateNumber instanceof CellError) { + return dateNumber + } + const date = Math.trunc(dateNumber) + if (this.dateTimeHelper.getWithinBounds(date) === undefined) { + return new CellError(ErrorType.NUM, ErrorMessage.DateBounds) + } + return date + } + + /** + * Converts the day-count `basis` argument of a securities function: a number as `strictNumber` accepts it, from 0 + * to less than 5, truncated to an integer. A negative value is #NUM! even when it truncates to 0, as in Excel. + */ + private coerceToDayCountBasis(value: InternalScalarValue): number | CellError { + const basis = this.strictNumber(value) + if (basis instanceof CellError) { + return basis + } + if (basis < 0) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall) + } + if (basis >= 5) { + return new CellError(ErrorType.NUM, ErrorMessage.ValueLarge) + } + return Math.trunc(basis) + } + + /** + * Converts an argument the way Excel does for these functions: numbers, dates, numeric text and empty cells are + * accepted, while a boolean or the empty text is #VALUE!. Errors are passed on. + */ + private strictNumber(value: InternalScalarValue): number | CellError { + if (value instanceof CellError) { + return value + } + if (typeof value === 'boolean' || value === '') { + return new CellError(ErrorType.VALUE, ErrorMessage.NumberCoercion) + } + const coerced = this.coerceScalarToNumberOrError(value) + return coerced instanceof CellError ? coerced : getRawValue(coerced) + } } function pmtCore(rate: number, periods: number, present: number, future: number, type: number): number { From b3989ed1dda5bc4640392079e85f04d75fc6fb85 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 7 Oct 2026 22:58:31 +0100 Subject: [PATCH 3/9] Link the pull request in the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 985f015c97..cd0af84707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added -- Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. +- Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) ### Fixed From 3ec6b6e27ac6e0d8f5cc9dbda1bed73c9e81b7b7 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 9 Oct 2026 21:36:26 +0100 Subject: [PATCH 4/9] Reject negative fractional dates in the securities functions coerceToSecurityDate truncated before checking the range, so a date such as -0.5 became serial 0, a valid date. Excel returns #NUM! for any negative date, before looking at the later arguments. Reject a negative value before truncating, as coerceToDayCountBasis already does for the basis. Co-Authored-By: Claude Opus 5.5 --- src/interpreter/plugin/FinancialPlugin.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index 1a0c28b3dd..a821df544e 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -1079,13 +1079,16 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp /** * Converts a date argument of a securities function: a number as `strictNumber` accepts it, truncated to an integer. - * A date outside the supported range is #NUM!. + * A date outside the supported range is #NUM!, and so is a negative value even when it truncates to 0, as in Excel. */ private coerceToSecurityDate(value: InternalScalarValue): number | CellError { const dateNumber = this.strictNumber(value) if (dateNumber instanceof CellError) { return dateNumber } + if (dateNumber < 0) { + return new CellError(ErrorType.NUM, ErrorMessage.DateBounds) + } const date = Math.trunc(dateNumber) if (this.dateTimeHelper.getWithinBounds(date) === undefined) { return new CellError(ErrorType.NUM, ErrorMessage.DateBounds) From a0261297382524e68286f0a3a04a8f3c58a97b7c Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 14 Oct 2026 12:33:42 +0100 Subject: [PATCH 5/9] Return #N/A for an empty required argument in the securities functions An empty required argument was coerced to 0, so DISC(,DATE(2008,2,16),97.975,100) returned a result for a settlement of serial 0. Excel returns #N/A. Check for an empty argument in its own place of the validation order, with a shared isArgumentEmpty helper. An empty basis is still 0 and an empty ACCRINTM par is still 1000. Co-Authored-By: Claude Opus 5.5 --- src/interpreter/plugin/FinancialPlugin.ts | 42 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index a821df544e..946fa27c8f 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -5,7 +5,7 @@ import {CellError, ErrorType} from '../../Cell' import {ErrorMessage} from '../../error-message' -import {ProcedureAst} from '../../parser' +import {AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' import { EmptyValue, @@ -791,7 +791,8 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp * Corresponds to ACCRINTM(issue, settlement, rate, [par], [basis]). * * Returns the accrued interest of a security that pays interest at maturity. An empty or omitted `par` is 1000. - * Arguments are validated from left to right, as in Excel. `issue` equal to `settlement` gives 0. + * Arguments are validated from left to right, as in Excel; an empty `issue`, `settlement` or `rate` is #N/A. + * `issue` equal to `settlement` gives 0. * * @param ast * @param state @@ -799,14 +800,23 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp public accrintm(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('ACCRINTM'), (issueArg: InternalScalarValue, settlementArg: InternalScalarValue, rateArg: InternalScalarValue, parArg: InternalScalarValue, basisArg: InternalScalarValue) => { + if (this.isArgumentEmpty(ast, 0)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const issue = this.coerceToSecurityDate(issueArg) if (issue instanceof CellError) { return issue } + if (this.isArgumentEmpty(ast, 1)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const settlement = this.coerceToSecurityDate(settlementArg) if (settlement instanceof CellError) { return settlement } + if (this.isArgumentEmpty(ast, 2)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const rate = this.strictNumber(rateArg) if (rate instanceof CellError) { return rate @@ -1029,8 +1039,9 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp * (settlement, maturity, first amount, second amount, [basis]). * * Validates in Excel's order: settlement and maturity, then basis, then the two amounts (errors and #VALUE!), - * then settlement before maturity and both amounts positive (#NUM!). Passes the day count and the days in the year - * between settlement and maturity to `calculate`. + * then settlement before maturity and both amounts positive (#NUM!). An empty settlement, maturity or amount is #N/A + * in its own place of that order. Passes the day count and the days in the year between settlement and maturity to + * `calculate`. * * @param {ProcedureAst} ast - the function's AST * @param {InterpreterState} state - the interpreter state @@ -1045,10 +1056,16 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ): InterpreterValue { return this.runFunction(ast.args, state, this.metadata(functionName), (settlementArg: InternalScalarValue, maturityArg: InternalScalarValue, firstAmountArg: InternalScalarValue, secondAmountArg: InternalScalarValue, basisArg: InternalScalarValue) => { + if (this.isArgumentEmpty(ast, 0)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const settlement = this.coerceToSecurityDate(settlementArg) if (settlement instanceof CellError) { return settlement } + if (this.isArgumentEmpty(ast, 1)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const maturity = this.coerceToSecurityDate(maturityArg) if (maturity instanceof CellError) { return maturity @@ -1057,10 +1074,16 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp if (basis instanceof CellError) { return basis } + if (this.isArgumentEmpty(ast, 2)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const firstAmount = this.strictNumber(firstAmountArg) if (firstAmount instanceof CellError) { return firstAmount } + if (this.isArgumentEmpty(ast, 3)) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } const secondAmount = this.strictNumber(secondAmountArg) if (secondAmount instanceof CellError) { return secondAmount @@ -1077,6 +1100,17 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ) } + /** + * Returns true when the argument at `index` is syntactically empty, as the first argument of `DISC(,A3,A4,A5)`. + * An omitted trailing argument is not empty. + * + * @param {ProcedureAst} ast - the function's AST + * @param {number} index - the argument's position, from 0 + */ + private isArgumentEmpty(ast: ProcedureAst, index: number): boolean { + return ast.args[index]?.type === AstNodeType.EMPTY + } + /** * Converts a date argument of a securities function: a number as `strictNumber` accepts it, truncated to an integer. * A date outside the supported range is #NUM!, and so is a negative value even when it truncates to 0, as in Excel. From 012b5cf0a25050880619a4443cc800c60b5f3fa8 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Wed, 14 Oct 2026 16:08:03 +0100 Subject: [PATCH 6/9] Move the changelog entry under Unreleased Merging develop after the 3.5.0 release left the entry for the new securities functions under the 3.5.0 heading. These functions are not in 3.5.0, so the entry belongs under Unreleased. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f75fb35df..75b2d89ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) + ## [3.5.0] - 2026-10-09 ### Added - Added support for the new license key format. A proprietary key can now grant a subset of the library: a function your key does not include evaluates to a `#LIC!` error, and the matching parts of the API throw a `LicenseCapabilityMissingError`. `getAvailableFunctions()` and `getFunctionDetails()` describe only the functions your key includes. [#1728](https://github.com/handsontable/hyperformula/pull/1728) -- Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) ### Changed From 53deeeef128ede236881cbdf327b0f28368d0c48 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 9 Oct 2026 20:01:50 +0100 Subject: [PATCH 7/9] feat(HF-373): add the coupon schedule and COUPPCD, COUPNCD and COUPNUM Add the coupon functions that return a date or a count from the coupon schedule, with the runner and schedule helpers the day-count functions build on. - couponFunction validates the arguments in Excel's order: settlement, maturity, settlement before maturity, an empty settlement, maturity or frequency argument (#N/A), frequency, then basis. Dates and basis use the securities helpers (strictNumber, coerceToSecurityDate, coerceToDayCountBasis) unchanged; frequency is truncated and must be 1, 2 or 4 (new message ErrorMessage.CouponFrequency). - couponDate steps from maturity by 12 / frequency months. A month-end maturity gives month-end coupon dates; otherwise maturity's day is clamped to the month, leap years included, and never drifts. - couponsAfter counts the coupon dates after settlement in constant time, without stepping through the periods. - A previous coupon date before the earliest supported date is #NUM!. Adds catalogue entries, names in all language packs, the license capability entries and a changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/error-message.ts | 1 + src/i18n/languages/csCZ.ts | 3 + src/i18n/languages/daDK.ts | 3 + src/i18n/languages/deDE.ts | 3 + src/i18n/languages/enGB.ts | 3 + src/i18n/languages/esES.ts | 3 + src/i18n/languages/fiFI.ts | 3 + src/i18n/languages/frFR.ts | 3 + src/i18n/languages/huHU.ts | 3 + src/i18n/languages/idID.ts | 3 + src/i18n/languages/itIT.ts | 3 + src/i18n/languages/nbNO.ts | 3 + src/i18n/languages/nlNL.ts | 3 + src/i18n/languages/plPL.ts | 3 + src/i18n/languages/ptPT.ts | 3 + src/i18n/languages/ruRU.ts | 3 + src/i18n/languages/svSE.ts | 3 + src/i18n/languages/trTR.ts | 3 + .../functionMetadata/categories/financial.ts | 21 ++ src/interpreter/plugin/FinancialPlugin.ts | 195 ++++++++++++++++++ src/license/functionCapabilities.ts | 3 +- 22 files changed, 271 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b2d89ec2..91c12ef3e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) +- Added new functions: COUPNCD, COUPNUM, COUPPCD. ## [3.5.0] - 2026-10-09 diff --git a/src/error-message.ts b/src/error-message.ts index d42040901c..82671202ed 100644 --- a/src/error-message.ts +++ b/src/error-message.ts @@ -26,6 +26,7 @@ export class ErrorMessage { public static IntegerExpected = 'Value needs to be an integer.' public static BadMode = 'Mode not recognized.' public static DateBounds = 'Date outside of bounds.' + public static CouponFrequency = 'Frequency must be 1, 2 or 4.' public static OutOfSheet = 'Resulting reference is out of the sheet.' public static WrongType = 'Wrong type of argument.' public static NaN = 'NaN or infinite value encountered.' diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index 5ae7308e86..f568c1583a 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'COUNTIF', COUNTIFS: 'COUNTIFS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'COUPNCD', + COUPNUM: 'COUPNUM', + COUPPCD: 'COUPPCD', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'CUMIPMT', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 38df364820..5980e67f10 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'TÆL.HVIS', COUNTIFS: 'TÆL.HVISER', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'KUPONDAG.NÆSTE', + COUPNUM: 'KUPONBETALINGER', + COUPPCD: 'KUPONDAG.FORRIGE', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'AKKUM.RENTE', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index 87d3f1c4e5..f33eefd754 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ZÄHLENWENN', COUNTIFS: 'ZÄHLENWENNS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'ZINSTERMNZ', + COUPNUM: 'ZINSTERMZAHL', + COUPPCD: 'ZINSTERMVZ', CSC: 'COSEC', CSCH: 'COSECHYP', CUMIPMT: 'KUMZINSZ', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 14fc955a11..5063284d1a 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -68,6 +68,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'COUNTIF', COUNTIFS: 'COUNTIFS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'COUPNCD', + COUPNUM: 'COUPNUM', + COUPPCD: 'COUPPCD', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'CUMIPMT', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index c9fddad644..49231dee69 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -67,6 +67,9 @@ export const dictionary: RawTranslationPackage = { COUNTIF: 'CONTAR.SI', COUNTIFS: 'CONTAR.SI.CONJUNTO', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'CUPON.FECHA.L2', + COUPNUM: 'CUPON.NUM', + COUPPCD: 'CUPON.FECHA.L1', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'PAGO.INT.ENTRE', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index 92ae19c978..a1ab72c95f 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'LASKE.JOS', COUNTIFS: 'LASKE.JOS.JOUKKO', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'KORKOPÄIVÄ.SEURAAVA', + COUPNUM: 'KORKOPÄIVÄ.JAKSOT', + COUPPCD: 'KORKOPÄIVÄ.EDELLINEN', CSC: 'KOSEK', CSCH: 'KOSEKH', CUMIPMT: 'MAKSETTU.KORKO', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 46ddc3341a..4911f28fa6 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'NB.SI', COUNTIFS: 'NB.SI.ENS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'DATE.COUPON.SUIV', + COUPNUM: 'NB.COUPONS', + COUPPCD: 'DATE.COUPON.PREC', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'CUMUL.INTER', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index be10141f63..c3da893761 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'DARABTELI', COUNTIFS: 'DARABHATÖBB', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'ELSŐ.SZELVÉNYDÁTUM', + COUPNUM: 'SZELVÉNYSZÁM', + COUPPCD: 'UTOLSÓ.SZELVÉNYDÁTUM', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'ÖSSZES.KAMAT', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 362748cb91..2f516af92f 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -68,6 +68,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'HITUNG.JIKA', COUNTIFS: 'HITUNG.JIKAH', COUNTUNIQUE: 'HITUNG.UNIK', + COUPNCD: 'COUPNCD', + COUPNUM: 'COUPNUM', + COUPPCD: 'COUPPCD', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'KUM.BUNGA', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 661d93f61d..90263a94bf 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'CONTA.SE', COUNTIFS: 'CONTA.PIÙ.SE', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'DATA.CED.SUCC', + COUPNUM: 'NUM.CED', + COUPPCD: 'DATA.CED.PREC', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'INT.CUMUL', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index f3049f460a..f0bded4e5f 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ANTALL.HVIS', COUNTIFS: 'ANTALL.HVIS.SETT', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'OBLIG.DAGER.EF', + COUPNUM: 'OBLIG.ANTALL', + COUPPCD: 'OBLIG.DAG.FORRIGE', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'SAMLET.RENTE', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index fa876c35df..e48515b15c 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'AANTAL.ALS', COUNTIFS: 'AANTALLEN.ALS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'COUP.DATUM.NB', + COUPNUM: 'COUP.AANTAL', + COUPPCD: 'COUP.DATUM.VB', CSC: 'COSEC', CSCH: 'COSECH', CUMIPMT: 'CUM.RENTE', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 73942b01c9..9fe560594e 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'LICZ.JEŻELI', COUNTIFS: 'LICZ.WARUNKI', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'WYPŁ.DATA.NAST', + COUPNUM: 'WYPŁ.LICZBA', + COUPPCD: 'WYPŁ.DATA.POPRZ', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'SPŁAC.ODS', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 3928f321a1..28ec1bfb54 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'CONT.SE', COUNTIFS: 'CONT.SES', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'CUPDATAPRÓX', + COUPNUM: 'CUPNÚM', + COUPPCD: 'CUPDATAANT', CSC: 'COSEC', CSCH: 'COSECH', CUMIPMT: 'PGTOJURACUM', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 17f8d6e854..4fc6add291 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'СЧЁТЕСЛИ', COUNTIFS: 'СЧЁТЕСЛИМН', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'ДАТАКУПОНПОСЛЕ', + COUPNUM: 'ЧИСЛКУПОН', + COUPPCD: 'ДАТАКУПОНДО', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'ОБЩПЛАТ', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 21ac96033f..760d4938bd 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ANTAL.OM', COUNTIFS: 'ANTAL.OMF', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'KUPNKD', + COUPNUM: 'KUPANT', + COUPPCD: 'KUPFKD', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'KUMRÄNTA', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index dc5a53cbb2..32d4c26cbd 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'EĞERSAY', COUNTIFS: 'ÇOKEĞERSAY', COUNTUNIQUE: 'COUNTUNIQUE', + COUPNCD: 'KUPONGÜNSKT', + COUPNUM: 'KUPONSAYI', + COUPPCD: 'KUPONGÜNÖKT', CSC: 'CSC', CSCH: 'CSCH', CUMIPMT: 'TOPÖDENENFAİZ', diff --git a/src/interpreter/functionMetadata/categories/financial.ts b/src/interpreter/functionMetadata/categories/financial.ts index febe1ab7c7..01f821703f 100644 --- a/src/interpreter/functionMetadata/categories/financial.ts +++ b/src/interpreter/functionMetadata/categories/financial.ts @@ -17,6 +17,27 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=ACCRINTM(DATE(2008,4,1), DATE(2008,6,15), 0.1, 1000, 3)', '=ACCRINTM(A1, B1, 0.05)'], }, + COUPNCD: { + category: 'Financial', + shortDescription: 'Returns the first coupon date after `settlement`, as a date serial number.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) to 4. It is validated but does not change the result.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPNCD(DATE(2011,1,25), DATE(2011,11,15), 2, 1)', '=COUPNCD(A1, B1, 4)'], + }, + COUPNUM: { + category: 'Financial', + shortDescription: 'Returns the number of coupons payable after `settlement`, up to and including `maturity`.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) to 4. It is validated but does not change the result.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPNUM(DATE(2007,1,25), DATE(2008,11,15), 2, 1)', '=COUPNUM(A1, B1, 4)'], + }, + COUPPCD: { + category: 'Financial', + shortDescription: 'Returns the last coupon date on or before `settlement`, as a date serial number.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) to 4. It is validated but does not change the result.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPPCD(DATE(2011,1,25), DATE(2011,11,15), 2, 1)', '=COUPPCD(A1, B1, 4)'], + }, CUMIPMT: { category: 'Financial', shortDescription: 'Returns the cumulative interest paid on a loan between a start period and an end period.', diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index 946fa27c8f..e70a42ae01 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -4,6 +4,7 @@ */ import {CellError, ErrorType} from '../../Cell' +import {offsetMonth, SimpleDate} from '../../DateTimeHelper' import {ErrorMessage} from '../../error-message' import {AstNodeType, ProcedureAst} from '../../parser' import {InterpreterState} from '../InterpreterState' @@ -19,6 +20,26 @@ import { import {SimpleRangeValue} from '../../SimpleRangeValue' import {FunctionArgumentType, FunctionPlugin, FunctionPluginTypecheck, ImplementedFunctions} from './FunctionPlugin' +/** + * The coupon schedule of a coupon function call, built once from its validated arguments. + */ +interface CouponSchedule { + /** The settlement date serial, truncated. */ + settlement: number, + /** The maturity date. */ + maturity: SimpleDate, + /** The number of coupons per year: 1, 2 or 4. */ + frequency: number, + /** The day-count basis: 0 to 4. */ + basis: number, + /** The number of coupon dates after settlement, up to and including maturity. */ + count: number, + /** The last coupon date on or before settlement. */ + previous: SimpleDate, + /** The first coupon date after settlement. */ + next: SimpleDate, +} + export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTypecheck { public static implementedFunctions: ImplementedFunctions = { 'PMT': { @@ -300,6 +321,33 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, ], }, + 'COUPPCD': { + method: 'couppcd', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'COUPNCD': { + method: 'coupncd', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'COUPNUM': { + method: 'coupnum', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, 'FVSCHEDULE': { method: 'fvschedule', parameters: [ @@ -841,6 +889,48 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ) } + /** + * Corresponds to COUPPCD(settlement, maturity, frequency, [basis]). + * + * Returns the last coupon date on or before the settlement date, as a date serial number. + * + * @param ast + * @param state + */ + public couppcd(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPPCD', + (schedule: CouponSchedule) => this.dateTimeHelper.dateToNumber(schedule.previous) + ) + } + + /** + * Corresponds to COUPNCD(settlement, maturity, frequency, [basis]). + * + * Returns the first coupon date after the settlement date, as a date serial number. + * + * @param ast + * @param state + */ + public coupncd(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPNCD', + (schedule: CouponSchedule) => this.dateTimeHelper.dateToNumber(schedule.next) + ) + } + + /** + * Corresponds to COUPNUM(settlement, maturity, frequency, [basis]). + * + * Returns the number of coupons payable after the settlement date, up to and including the maturity date. + * + * @param ast + * @param state + */ + public coupnum(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPNUM', + (schedule: CouponSchedule) => schedule.count + ) + } + public fvschedule(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('FVSCHEDULE'), (value: number, ratios: SimpleRangeValue) => { @@ -1111,6 +1201,65 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp return ast.args[index]?.type === AstNodeType.EMPTY } + /** + * Runs COUPPCD, COUPNCD and COUPNUM, which share the arguments + * (settlement, maturity, frequency, [basis]). + * + * Validates in Excel's order: settlement, maturity, settlement before maturity, then an empty settlement, maturity + * or frequency argument (#N/A), then frequency and basis. An empty date is not compared with the other date. Builds + * the coupon schedule and passes it to `calculate`. A previous coupon date before the earliest supported date is + * #NUM!. + * + * @param {ProcedureAst} ast - the function's AST + * @param {InterpreterState} state - the interpreter state + * @param {string} functionName - the function's id, used to look up its metadata + * @param {Function} calculate - computes the result from the coupon schedule + */ + private couponFunction( + ast: ProcedureAst, + state: InterpreterState, + functionName: string, + calculate: (schedule: CouponSchedule) => number, + ): InterpreterValue { + const isArgumentEmpty = (index: number) => ast.args[index]?.type === AstNodeType.EMPTY + const isDateEmpty = isArgumentEmpty(0) || isArgumentEmpty(1) + const isFrequencyEmpty = isArgumentEmpty(2) + return this.runFunction(ast.args, state, this.metadata(functionName), + (settlementArg: InternalScalarValue, maturityArg: InternalScalarValue, frequencyArg: InternalScalarValue, basisArg: InternalScalarValue) => { + const settlement = this.coerceToSecurityDate(settlementArg) + if (settlement instanceof CellError) { + return settlement + } + const maturity = this.coerceToSecurityDate(maturityArg) + if (maturity instanceof CellError) { + return maturity + } + if (!isDateEmpty && settlement >= maturity) { + return new CellError(ErrorType.NUM, ErrorMessage.StartEndDate) + } + if (isDateEmpty || isFrequencyEmpty) { + return new CellError(ErrorType.NA, ErrorMessage.EmptyArg) + } + const frequency = this.coerceToCouponFrequency(frequencyArg) + if (frequency instanceof CellError) { + return frequency + } + const basis = this.coerceToDayCountBasis(basisArg) + if (basis instanceof CellError) { + return basis + } + const maturityDate = this.dateTimeHelper.numberToSimpleDate(maturity) + const count = this.couponsAfter(settlement, maturityDate, frequency) + const previous = this.couponDate(maturityDate, -count, frequency) + if (this.dateTimeHelper.dateToNumber(previous) < 0) { + return new CellError(ErrorType.NUM, ErrorMessage.DateBounds) + } + const next = this.couponDate(maturityDate, 1 - count, frequency) + return calculate({settlement, maturity: maturityDate, frequency, basis, count, previous, next}) + } + ) + } + /** * Converts a date argument of a securities function: a number as `strictNumber` accepts it, truncated to an integer. * A date outside the supported range is #NUM!, and so is a negative value even when it truncates to 0, as in Excel. @@ -1148,6 +1297,22 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp return Math.trunc(basis) } + /** + * Converts the `frequency` argument of a coupon function: a number as `strictNumber` accepts it, truncated to an + * integer, that must be 1 (annual), 2 (semiannual) or 4 (quarterly). + */ + private coerceToCouponFrequency(value: InternalScalarValue): number | CellError { + const frequency = this.strictNumber(value) + if (frequency instanceof CellError) { + return frequency + } + const truncated = Math.trunc(frequency) + if (truncated !== 1 && truncated !== 2 && truncated !== 4) { + return new CellError(ErrorType.NUM, ErrorMessage.CouponFrequency) + } + return truncated + } + /** * Converts an argument the way Excel does for these functions: numbers, dates, numeric text and empty cells are * accepted, while a boolean or the empty text is #VALUE!. Errors are passed on. @@ -1162,6 +1327,36 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp const coerced = this.coerceScalarToNumberOrError(value) return coerced instanceof CellError ? coerced : getRawValue(coerced) } + + /** + * Returns the coupon date `periods` coupon periods after `anchor` (before it when negative). The date is computed + * from the anchor directly, so the day of the month never drifts: if the anchor is the last day of its month, so is + * the result; otherwise the result keeps the anchor's day, clamped to the length of its month (leap years included). + */ + private couponDate(anchor: SimpleDate, periods: number, frequency: number): SimpleDate { + const shifted = offsetMonth(anchor, periods * 12 / frequency) + const lastDay = this.dateTimeHelper.daysInMonth(shifted.year, shifted.month) + const isAnchorMonthEnd = anchor.day === this.dateTimeHelper.daysInMonth(anchor.year, anchor.month) + return {...shifted, day: isAnchorMonthEnd ? lastDay : Math.min(anchor.day, lastDay)} + } + + /** + * Returns the number of coupon dates after `settlement`, up to and including `maturity`: the smallest k >= 1 for + * which the coupon date k periods before maturity is on or before settlement. + * + * Runs in constant time instead of stepping through the periods (a quarterly schedule over the whole supported date + * range has 32,400 of them): the whole periods in the months between settlement and maturity give a coupon date in + * settlement's month or later, and one period more gives a date before settlement's month. + */ + private couponsAfter(settlement: number, maturity: SimpleDate, frequency: number): number { + const settlementDate = this.dateTimeHelper.numberToSimpleDate(settlement) + const months = 12 * (maturity.year - settlementDate.year) + maturity.month - settlementDate.month + const periods = Math.floor(months * frequency / 12) + if (periods > 0 && this.dateTimeHelper.dateToNumber(this.couponDate(maturity, -periods, frequency)) <= settlement) { + return periods + } + return periods + 1 + } } function pmtCore(rate: number, periods: number, present: number, future: number, type: number): number { diff --git a/src/license/functionCapabilities.ts b/src/license/functionCapabilities.ts index 0161886524..5af20a9203 100644 --- a/src/license/functionCapabilities.ts +++ b/src/license/functionCapabilities.ts @@ -80,7 +80,8 @@ const UNGROUPED_FUNCTIONS = [ 'BESSELY', 'BETA.DIST', 'BETA.INV', 'BIN2DEC', 'BIN2HEX', 'BIN2OCT', 'BINOM.DIST', 'BINOM.INV', 'BITAND', 'BITLSHIFT', 'BITOR', 'BITRSHIFT', 'BITXOR', 'CEILING.MATH', 'CEILING.PRECISE', 'CHISQ.DIST', 'CHISQ.DIST.RT', 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'COMBIN', 'COMBINA', 'COMPLEX', - 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COVARIANCE.P', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COUPNCD', + 'COUPNUM', 'COUPPCD', 'COVARIANCE.P', 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', 'DCOUNT', 'DCOUNTA', 'DDB', 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DISC', 'DMAX', 'DMIN', 'DOLLARDE', 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', From 46e48bd34104624cd16116acdacee6152599e3d5 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 9 Oct 2026 20:10:15 +0100 Subject: [PATCH 8/9] feat(HF-373): add COUPDAYBS, COUPDAYSNC and COUPDAYS Add the coupon day-count functions on top of the coupon schedule. - COUPDAYBS counts from the previous coupon date to settlement with the day-count helper for every basis. - COUPDAYSNC uses the same helper for bases 1 to 4. For basis 0 it is the 30/360 length of the coupon period, with day 31 and the last day of February counted as 30 at both ends, minus COUPDAYBS, as in Excel. The three functions do not always add up, in Excel or here. - COUPDAYS is 360 / frequency for bases 0, 2 and 4 and 365 / frequency for basis 3. For basis 1 it is Excel's period length, which is not always the next coupon date minus the previous one: a walk back from maturity whose day can drift down at month ends, followed by an end-of-month step back. Computed in constant time. Adds catalogue entries, names in all language packs, the license capability entries and the changelog entry. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- src/i18n/languages/csCZ.ts | 3 + src/i18n/languages/daDK.ts | 3 + src/i18n/languages/deDE.ts | 3 + src/i18n/languages/enGB.ts | 3 + src/i18n/languages/esES.ts | 3 + src/i18n/languages/fiFI.ts | 3 + src/i18n/languages/frFR.ts | 3 + src/i18n/languages/huHU.ts | 3 + src/i18n/languages/idID.ts | 3 + src/i18n/languages/itIT.ts | 3 + src/i18n/languages/nbNO.ts | 3 + src/i18n/languages/nlNL.ts | 3 + src/i18n/languages/plPL.ts | 3 + src/i18n/languages/ptPT.ts | 3 + src/i18n/languages/ruRU.ts | 3 + src/i18n/languages/svSE.ts | 3 + src/i18n/languages/trTR.ts | 3 + .../functionMetadata/categories/financial.ts | 21 +++ src/interpreter/plugin/FinancialPlugin.ts | 162 +++++++++++++++++- src/license/functionCapabilities.ts | 4 +- 21 files changed, 236 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c12ef3e9..fcc13ff1a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) -- Added new functions: COUPNCD, COUPNUM, COUPPCD. +- Added new functions: COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM, COUPPCD. ## [3.5.0] - 2026-10-09 diff --git a/src/i18n/languages/csCZ.ts b/src/i18n/languages/csCZ.ts index f568c1583a..437f4a5c3f 100644 --- a/src/i18n/languages/csCZ.ts +++ b/src/i18n/languages/csCZ.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'COUNTIF', COUNTIFS: 'COUNTIFS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'COUPDAYBS', + COUPDAYS: 'COUPDAYS', + COUPDAYSNC: 'COUPDAYSNC', COUPNCD: 'COUPNCD', COUPNUM: 'COUPNUM', COUPPCD: 'COUPPCD', diff --git a/src/i18n/languages/daDK.ts b/src/i18n/languages/daDK.ts index 5980e67f10..e96d2b6582 100644 --- a/src/i18n/languages/daDK.ts +++ b/src/i18n/languages/daDK.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'TÆL.HVIS', COUNTIFS: 'TÆL.HVISER', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'KUPONDAGE.SA', + COUPDAYS: 'KUPONDAGE.A', + COUPDAYSNC: 'KUPONDAGE.ANK', COUPNCD: 'KUPONDAG.NÆSTE', COUPNUM: 'KUPONBETALINGER', COUPPCD: 'KUPONDAG.FORRIGE', diff --git a/src/i18n/languages/deDE.ts b/src/i18n/languages/deDE.ts index f33eefd754..281eab8aef 100644 --- a/src/i18n/languages/deDE.ts +++ b/src/i18n/languages/deDE.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ZÄHLENWENN', COUNTIFS: 'ZÄHLENWENNS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'ZINSTERMTAGVA', + COUPDAYS: 'ZINSTERMTAGE', + COUPDAYSNC: 'ZINSTERMTAGNZ', COUPNCD: 'ZINSTERMNZ', COUPNUM: 'ZINSTERMZAHL', COUPPCD: 'ZINSTERMVZ', diff --git a/src/i18n/languages/enGB.ts b/src/i18n/languages/enGB.ts index 5063284d1a..a10a2ae8f8 100644 --- a/src/i18n/languages/enGB.ts +++ b/src/i18n/languages/enGB.ts @@ -68,6 +68,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'COUNTIF', COUNTIFS: 'COUNTIFS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'COUPDAYBS', + COUPDAYS: 'COUPDAYS', + COUPDAYSNC: 'COUPDAYSNC', COUPNCD: 'COUPNCD', COUPNUM: 'COUPNUM', COUPPCD: 'COUPPCD', diff --git a/src/i18n/languages/esES.ts b/src/i18n/languages/esES.ts index 49231dee69..ddd3253293 100644 --- a/src/i18n/languages/esES.ts +++ b/src/i18n/languages/esES.ts @@ -67,6 +67,9 @@ export const dictionary: RawTranslationPackage = { COUNTIF: 'CONTAR.SI', COUNTIFS: 'CONTAR.SI.CONJUNTO', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'CUPON.DIAS.L1', + COUPDAYS: 'CUPON.DIAS', + COUPDAYSNC: 'CUPON.DIAS.L2', COUPNCD: 'CUPON.FECHA.L2', COUPNUM: 'CUPON.NUM', COUPPCD: 'CUPON.FECHA.L1', diff --git a/src/i18n/languages/fiFI.ts b/src/i18n/languages/fiFI.ts index a1ab72c95f..c7df87e232 100644 --- a/src/i18n/languages/fiFI.ts +++ b/src/i18n/languages/fiFI.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'LASKE.JOS', COUNTIFS: 'LASKE.JOS.JOUKKO', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'KORKOPÄIVÄT.ALUSTA', + COUPDAYS: 'KORKOPÄIVÄT', + COUPDAYSNC: 'KORKOPÄIVÄT.SEURAAVA', COUPNCD: 'KORKOPÄIVÄ.SEURAAVA', COUPNUM: 'KORKOPÄIVÄ.JAKSOT', COUPPCD: 'KORKOPÄIVÄ.EDELLINEN', diff --git a/src/i18n/languages/frFR.ts b/src/i18n/languages/frFR.ts index 4911f28fa6..4139099fd4 100644 --- a/src/i18n/languages/frFR.ts +++ b/src/i18n/languages/frFR.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'NB.SI', COUNTIFS: 'NB.SI.ENS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'NB.JOURS.COUPON.PREC', + COUPDAYS: 'NB.JOURS.COUPONS', + COUPDAYSNC: 'NB.JOURS.COUPON.SUIV', COUPNCD: 'DATE.COUPON.SUIV', COUPNUM: 'NB.COUPONS', COUPPCD: 'DATE.COUPON.PREC', diff --git a/src/i18n/languages/huHU.ts b/src/i18n/languages/huHU.ts index c3da893761..c137975c79 100644 --- a/src/i18n/languages/huHU.ts +++ b/src/i18n/languages/huHU.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'DARABTELI', COUNTIFS: 'DARABHATÖBB', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'SZELVÉNYIDŐ.KEZDETTŐL', + COUPDAYS: 'SZELVÉNYIDŐ', + COUPDAYSNC: 'SZELVÉNYIDŐ.KIFIZETÉSTŐL', COUPNCD: 'ELSŐ.SZELVÉNYDÁTUM', COUPNUM: 'SZELVÉNYSZÁM', COUPPCD: 'UTOLSÓ.SZELVÉNYDÁTUM', diff --git a/src/i18n/languages/idID.ts b/src/i18n/languages/idID.ts index 2f516af92f..f27fafbc2b 100644 --- a/src/i18n/languages/idID.ts +++ b/src/i18n/languages/idID.ts @@ -68,6 +68,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'HITUNG.JIKA', COUNTIFS: 'HITUNG.JIKAH', COUNTUNIQUE: 'HITUNG.UNIK', + COUPDAYBS: 'COUPDAYBS', + COUPDAYS: 'COUPDAYS', + COUPDAYSNC: 'COUPDAYSNC', COUPNCD: 'COUPNCD', COUPNUM: 'COUPNUM', COUPPCD: 'COUPPCD', diff --git a/src/i18n/languages/itIT.ts b/src/i18n/languages/itIT.ts index 90263a94bf..292ac16e47 100644 --- a/src/i18n/languages/itIT.ts +++ b/src/i18n/languages/itIT.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'CONTA.SE', COUNTIFS: 'CONTA.PIÙ.SE', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'GIORNI.CED.INIZ.LIQ', + COUPDAYS: 'GIORNI.CED', + COUPDAYSNC: 'GIORNI.CED.NUOVA', COUPNCD: 'DATA.CED.SUCC', COUPNUM: 'NUM.CED', COUPPCD: 'DATA.CED.PREC', diff --git a/src/i18n/languages/nbNO.ts b/src/i18n/languages/nbNO.ts index f0bded4e5f..ab306c1c83 100644 --- a/src/i18n/languages/nbNO.ts +++ b/src/i18n/languages/nbNO.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ANTALL.HVIS', COUNTIFS: 'ANTALL.HVIS.SETT', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'OBLIG.DAGER.FF', + COUPDAYS: 'OBLIG.DAGER', + COUPDAYSNC: 'OBLIG.DAGER.NF', COUPNCD: 'OBLIG.DAGER.EF', COUPNUM: 'OBLIG.ANTALL', COUPPCD: 'OBLIG.DAG.FORRIGE', diff --git a/src/i18n/languages/nlNL.ts b/src/i18n/languages/nlNL.ts index e48515b15c..0b6bad4023 100644 --- a/src/i18n/languages/nlNL.ts +++ b/src/i18n/languages/nlNL.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'AANTAL.ALS', COUNTIFS: 'AANTALLEN.ALS', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'COUP.DAGEN.BB', + COUPDAYS: 'COUP.DAGEN', + COUPDAYSNC: 'COUP.DAGEN.VV', COUPNCD: 'COUP.DATUM.NB', COUPNUM: 'COUP.AANTAL', COUPPCD: 'COUP.DATUM.VB', diff --git a/src/i18n/languages/plPL.ts b/src/i18n/languages/plPL.ts index 9fe560594e..a45126ee1c 100644 --- a/src/i18n/languages/plPL.ts +++ b/src/i18n/languages/plPL.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'LICZ.JEŻELI', COUNTIFS: 'LICZ.WARUNKI', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'WYPŁ.DNI.OD.POCZ', + COUPDAYS: 'WYPŁ.DNI', + COUPDAYSNC: 'WYPŁ.DNI.NAST', COUPNCD: 'WYPŁ.DATA.NAST', COUPNUM: 'WYPŁ.LICZBA', COUPPCD: 'WYPŁ.DATA.POPRZ', diff --git a/src/i18n/languages/ptPT.ts b/src/i18n/languages/ptPT.ts index 28ec1bfb54..038a527ab1 100644 --- a/src/i18n/languages/ptPT.ts +++ b/src/i18n/languages/ptPT.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'CONT.SE', COUNTIFS: 'CONT.SES', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'CUPDIASINLIQ', + COUPDAYS: 'CUPDIAS', + COUPDAYSNC: 'CUPDIASPRÓX', COUPNCD: 'CUPDATAPRÓX', COUPNUM: 'CUPNÚM', COUPPCD: 'CUPDATAANT', diff --git a/src/i18n/languages/ruRU.ts b/src/i18n/languages/ruRU.ts index 4fc6add291..f52e8b5bae 100644 --- a/src/i18n/languages/ruRU.ts +++ b/src/i18n/languages/ruRU.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'СЧЁТЕСЛИ', COUNTIFS: 'СЧЁТЕСЛИМН', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'ДНЕЙКУПОНДО', + COUPDAYS: 'ДНЕЙКУПОН', + COUPDAYSNC: 'ДНЕЙКУПОНПОСЛЕ', COUPNCD: 'ДАТАКУПОНПОСЛЕ', COUPNUM: 'ЧИСЛКУПОН', COUPPCD: 'ДАТАКУПОНДО', diff --git a/src/i18n/languages/svSE.ts b/src/i18n/languages/svSE.ts index 760d4938bd..f0f128804c 100644 --- a/src/i18n/languages/svSE.ts +++ b/src/i18n/languages/svSE.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'ANTAL.OM', COUNTIFS: 'ANTAL.OMF', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'KUPDAGBB', + COUPDAYS: 'KUPDAGB', + COUPDAYSNC: 'KUPDAGNK', COUPNCD: 'KUPNKD', COUPNUM: 'KUPANT', COUPPCD: 'KUPFKD', diff --git a/src/i18n/languages/trTR.ts b/src/i18n/languages/trTR.ts index 32d4c26cbd..13a085b09a 100644 --- a/src/i18n/languages/trTR.ts +++ b/src/i18n/languages/trTR.ts @@ -67,6 +67,9 @@ const dictionary: RawTranslationPackage = { COUNTIF: 'EĞERSAY', COUNTIFS: 'ÇOKEĞERSAY', COUNTUNIQUE: 'COUNTUNIQUE', + COUPDAYBS: 'KUPONGÜNBD', + COUPDAYS: 'KUPONGÜN', + COUPDAYSNC: 'KUPONGÜNDSK', COUPNCD: 'KUPONGÜNSKT', COUPNUM: 'KUPONSAYI', COUPPCD: 'KUPONGÜNÖKT', diff --git a/src/interpreter/functionMetadata/categories/financial.ts b/src/interpreter/functionMetadata/categories/financial.ts index 01f821703f..32fd7d561e 100644 --- a/src/interpreter/functionMetadata/categories/financial.ts +++ b/src/interpreter/functionMetadata/categories/financial.ts @@ -17,6 +17,27 @@ export const FINANCIAL_DOCS: Record = { documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', examples: ['=ACCRINTM(DATE(2008,4,1), DATE(2008,6,15), 0.1, 1000, 3)', '=ACCRINTM(A1, B1, 0.05)'], }, + COUPDAYBS: { + category: 'Financial', + shortDescription: 'Returns the number of days from the beginning of the coupon period to `settlement`.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPDAYBS(DATE(2011,1,25), DATE(2011,11,15), 2, 1)', '=COUPDAYBS(A1, B1, 4)'], + }, + COUPDAYS: { + category: 'Financial', + shortDescription: 'Returns the number of days in the coupon period that contains `settlement`.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPDAYS(DATE(2011,1,25), DATE(2011,11,15), 2, 1)', '=COUPDAYS(A1, B1, 4)'], + }, + COUPDAYSNC: { + category: 'Financial', + shortDescription: 'Returns the number of days from `settlement` to the next coupon date.', + parameters: [{name: 'settlement', description: 'The settlement date, as a date serial number, truncated to an integer.'}, {name: 'maturity', description: 'The maturity date, as a date serial number, truncated to an integer; must be later than `settlement`.'}, {name: 'frequency', description: 'The number of coupon payments per year, truncated to an integer: 1 (annual), 2 (semiannual) or 4 (quarterly).'}, {name: 'basis', description: 'The day-count basis, truncated to an integer: 0 (default) US 30/360, 1 actual/actual, 2 actual/360, 3 actual/365, 4 European 30/360.'}], + documentationUrl: 'https://hyperformula.handsontable.com/docs/guide/built-in-functions.html', + examples: ['=COUPDAYSNC(DATE(2011,1,25), DATE(2011,11,15), 2, 1)', '=COUPDAYSNC(A1, B1, 4)'], + }, COUPNCD: { category: 'Financial', shortDescription: 'Returns the first coupon date after `settlement`, as a date serial number.', diff --git a/src/interpreter/plugin/FinancialPlugin.ts b/src/interpreter/plugin/FinancialPlugin.ts index e70a42ae01..3dd36bf632 100644 --- a/src/interpreter/plugin/FinancialPlugin.ts +++ b/src/interpreter/plugin/FinancialPlugin.ts @@ -348,6 +348,33 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, ], }, + 'COUPDAYBS': { + method: 'coupdaybs', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'COUPDAYSNC': { + method: 'coupdaysnc', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, + 'COUPDAYS': { + method: 'coupdays', + parameters: [ + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR}, + {argumentType: FunctionArgumentType.SCALAR, defaultValue: 0}, + ], + }, 'FVSCHEDULE': { method: 'fvschedule', parameters: [ @@ -931,6 +958,65 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp ) } + /** + * Corresponds to COUPDAYBS(settlement, maturity, frequency, [basis]). + * + * Returns the number of days from the previous coupon date to the settlement date, counted by `basis`. + * + * @param ast + * @param state + */ + public coupdaybs(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPDAYBS', + (schedule: CouponSchedule) => this.daysBeforeSettlement(schedule) + ) + } + + /** + * Corresponds to COUPDAYSNC(settlement, maturity, frequency, [basis]). + * + * Returns the number of days from the settlement date to the next coupon date. Basis 0 counts it as Excel does: + * the 30/360 length of the coupon period minus COUPDAYBS, so it is not always the direct 30/360 count. + * + * @param ast + * @param state + */ + public coupdaysnc(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPDAYSNC', + (schedule: CouponSchedule) => { + if (schedule.basis === 0) { + return this.thirtyDayPeriodLength(schedule) - this.daysBeforeSettlement(schedule) + } + const next = this.dateTimeHelper.dateToNumber(schedule.next) + return this.dateTimeHelper.dayCountByBasis(schedule.settlement, next, schedule.basis).dayCount + } + ) + } + + /** + * Corresponds to COUPDAYS(settlement, maturity, frequency, [basis]). + * + * Returns the number of days in the coupon period that contains the settlement date: 360 / `frequency` for bases + * 0, 2 and 4, 365 / `frequency` for basis 3, and the actual length of the period, as Excel computes it, for basis 1. + * + * @param ast + * @param state + */ + public coupdays(ast: ProcedureAst, state: InterpreterState): InterpreterValue { + return this.couponFunction(ast, state, 'COUPDAYS', + (schedule: CouponSchedule) => { + switch (schedule.basis) { + case 1: + return this.actualCouponPeriodLength(schedule) + case 3: + return 365 / schedule.frequency + default: + return 360 / schedule.frequency + } + } + ) + } + public fvschedule(ast: ProcedureAst, state: InterpreterState): InterpreterValue { return this.runFunction(ast.args, state, this.metadata('FVSCHEDULE'), (value: number, ratios: SimpleRangeValue) => { @@ -1202,7 +1288,7 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp } /** - * Runs COUPPCD, COUPNCD and COUPNUM, which share the arguments + * Runs COUPPCD, COUPNCD, COUPNUM, COUPDAYBS, COUPDAYSNC and COUPDAYS, which share the arguments * (settlement, maturity, frequency, [basis]). * * Validates in Excel's order: settlement, maturity, settlement before maturity, then an empty settlement, maturity @@ -1357,6 +1443,80 @@ export class FinancialPlugin extends FunctionPlugin implements FunctionPluginTyp } return periods + 1 } + + /** + * Returns the number of days from the previous coupon date to settlement, counted by the schedule's basis. + */ + private daysBeforeSettlement(schedule: CouponSchedule): number { + const previous = this.dateTimeHelper.dateToNumber(schedule.previous) + return this.dateTimeHelper.dayCountByBasis(previous, schedule.settlement, schedule.basis).dayCount + } + + /** + * Returns the 30/360 length of the coupon period, from the previous to the next coupon date, as Excel counts it for + * COUPDAYSNC with basis 0: day 31 and the last day of February count as day 30 at both ends. The two dates are + * exactly 12 / `frequency` months apart, so only their days change the length (for example 179 days from + * 28 February to 29 August). + */ + private thirtyDayPeriodLength(schedule: CouponSchedule): number { + return 360 / schedule.frequency + this.thirtyDayMonthDay(schedule.next) - this.thirtyDayMonthDay(schedule.previous) + } + + /** + * Returns the day of the month of `date` in a 30-day month: 30 for day 31 and for the last day of February, + * otherwise the day itself. + */ + private thirtyDayMonthDay(date: SimpleDate): number { + const isFebruaryEnd = date.month === 2 && date.day === this.dateTimeHelper.daysInMonth(date.year, 2) + return date.day === 31 || isFebruaryEnd ? 30 : date.day + } + + /** + * Returns the length in days of the coupon period that contains settlement, as Excel's COUPDAYS computes it for + * basis 1. It is not always the next coupon date minus the previous one. + * + * 1. Step back from maturity one period at a time, each date keeping the previous date's day clamped to its month + * (so 31 March, 30 September, 30 March: the day can only go down). Stop at the last date whose next step back is + * on or before settlement: the period end. + * 2. Step back once from the period end with the end-of-month rule of `couponDate`: the period start. + * 3. If the period start is still after settlement, it becomes the period end and step 2 repeats. This happens at + * most once. + * + * The result is the period end minus the period start. Constant time: the period end is the drifted date + * `count - 1` periods back, or `count - 2` when that one is already on or before settlement. + */ + private actualCouponPeriodLength(schedule: CouponSchedule): number { + const {settlement, maturity, frequency, count} = schedule + let periodEnd = this.driftedCouponDate(maturity, count - 1, frequency) + if (count >= 2 && this.dateTimeHelper.dateToNumber(periodEnd) <= settlement) { + periodEnd = this.driftedCouponDate(maturity, count - 2, frequency) + } + let periodStart = this.couponDate(periodEnd, -1, frequency) + if (this.dateTimeHelper.dateToNumber(periodStart) > settlement) { + periodEnd = periodStart + periodStart = this.couponDate(periodEnd, -1, frequency) + } + return this.dateTimeHelper.dateToNumber(periodEnd) - this.dateTimeHelper.dateToNumber(periodStart) + } + + /** + * Returns `maturity` stepped back `periods` coupon periods, each step keeping the previous date's day clamped to the + * length of its month, so the day can drift down (31 March, 30 September, 30 March). + * + * Constant time: the drifted day is the smallest of maturity's day and the lengths of the months visited, and those + * months repeat every year, so after `2 * frequency` steps (two Februaries, at least one in a non-leap year) the + * day no longer changes. + */ + private driftedCouponDate(maturity: SimpleDate, periods: number, frequency: number): SimpleDate { + const monthsPerPeriod = 12 / frequency + let day = maturity.day + for (let step = 1; step <= Math.min(periods, 2 * frequency); step++) { + const visited = offsetMonth(maturity, -step * monthsPerPeriod) + day = Math.min(day, this.dateTimeHelper.daysInMonth(visited.year, visited.month)) + } + const target = offsetMonth(maturity, -periods * monthsPerPeriod) + return {...target, day: Math.min(day, this.dateTimeHelper.daysInMonth(target.year, target.month))} + } } function pmtCore(rate: number, periods: number, present: number, future: number, type: number): number { diff --git a/src/license/functionCapabilities.ts b/src/license/functionCapabilities.ts index 5af20a9203..65e7586a7e 100644 --- a/src/license/functionCapabilities.ts +++ b/src/license/functionCapabilities.ts @@ -80,8 +80,8 @@ const UNGROUPED_FUNCTIONS = [ 'BESSELY', 'BETA.DIST', 'BETA.INV', 'BIN2DEC', 'BIN2HEX', 'BIN2OCT', 'BINOM.DIST', 'BINOM.INV', 'BITAND', 'BITLSHIFT', 'BITOR', 'BITRSHIFT', 'BITXOR', 'CEILING.MATH', 'CEILING.PRECISE', 'CHISQ.DIST', 'CHISQ.DIST.RT', 'CHISQ.INV', 'CHISQ.INV.RT', 'CHISQ.TEST', 'COMBIN', 'COMBINA', 'COMPLEX', - 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COUPNCD', - 'COUPNUM', 'COUPPCD', 'COVARIANCE.P', + 'CONFIDENCE.NORM', 'CONFIDENCE.T', 'CORREL', 'COSH', 'COT', 'COTH', 'COUNTUNIQUE', 'COUPDAYBS', + 'COUPDAYS', 'COUPDAYSNC', 'COUPNCD', 'COUPNUM', 'COUPPCD', 'COVARIANCE.P', 'COVARIANCE.S', 'CSC', 'CSCH', 'CUMIPMT', 'CUMPRINC', 'DAVERAGE', 'DB', 'DCOUNT', 'DCOUNTA', 'DDB', 'DEC2BIN', 'DEC2OCT', 'DECIMAL', 'DEGREES', 'DELTA', 'DEVSQ', 'DGET', 'DISC', 'DMAX', 'DMIN', 'DOLLARDE', 'DOLLARFR', 'DPRODUCT', 'DSTDEV', 'DSTDEVP', 'DSUM', 'DVAR', 'DVARP', 'EFFECT', 'ERF', 'ERFC', From 442a5e3f5c007379bbb07a60c5a7bc93f29f2ac8 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 9 Oct 2026 20:23:34 +0100 Subject: [PATCH 9/9] docs(HF-373): link the pull request in the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc13ff1a7..4aa18aec8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - Added new functions: ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC. [#1799](https://github.com/handsontable/hyperformula/pull/1799) -- Added new functions: COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM, COUPPCD. +- Added new functions: COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM, COUPPCD. [#1804](https://github.com/handsontable/hyperformula/pull/1804) ## [3.5.0] - 2026-10-09