From ff50a6623499d3eb69ae12a549e39c9c66a7d4c9 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 11:38:14 -0400 Subject: [PATCH 1/3] Count YEARFRAC's basis 4 as the European 30/360 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basis 4 pulls a day-31 back to the 30th at both ends of the span before differencing. The arm reached the day numbers untouched, so neither endpoint moved and the count was a raw 30/360 — a day too many whenever one end fell on the 31st and the other did not. Coverage cases pin a day-31 start, a day-31 end, the span with both where the two shifts cancel, and that day-31 end on basis 0, whose US rule reaches the end date only once the start has landed on the 30th. Co-Authored-By: Claude Opus 5 (1M context) --- packages/bxl/CHANGELOG.md | 11 ++++- packages/bxl/src/formulajs/UPSTREAM-DIFFS.md | 43 ++++++++++--------- packages/bxl/src/formulajs/dateSerial.ts | 6 +++ .../function-coverage/formula-date.ts | 39 +++++++++++++++++ 4 files changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/bxl/CHANGELOG.md b/packages/bxl/CHANGELOG.md index 6e95e43536e..5e3d7a41800 100644 --- a/packages/bxl/CHANGELOG.md +++ b/packages/bxl/CHANGELOG.md @@ -63,7 +63,7 @@ versions may change syntax behavior until `1.0.0`. `gsub` can read them, and a group that did not participate reports an absent capture rather than crashing; `round` ties away from zero; `isinfinite` excludes NaN, and `isfinite` with it; `lgamma_r` returns its `[magnitude, - sign]` pair; `scalars_or_empty` keeps empty collections; `max_by` breaks ties +sign]` pair; `scalars_or_empty` keeps empty collections; `max_by` breaks ties on the last maximum, as jq does; `inputs` yields an empty stream. On the Excel side: `PROPER`, `TRIM`, `SEARCH` (wildcards), `SUBSTITUTE` (an occurrence at position 0), `TEXT` (date format codes), `NUMBERVALUE` (percent signs and @@ -89,6 +89,15 @@ versions may change syntax behavior until `1.0.0`. single numbers in both readable and canonical-jq syntax, where the tokenizer previously ended the literal at the first digit and read the rest as a name. +- **`YEARFRAC` on basis 4 counts the European 30/360 it names.** The convention + pulls a day-31 back to the 30th at both ends of the span before differencing; + leaving both endpoints where they stand is a raw 30/360, which counted a day + too many whenever one end fell on the 31st and the other did not — small, but + always in the same direction. Basis 4 reaches `DISC`, `PRICEDISC` and + `ACCRINT` as well as `YEARFRAC` itself. Basis 0's US rule is a different + convention and keeps its own asymmetry, where the end date moves only once + the start has landed on the 30th. + ### Removed - **`BXL_BUILD_INFO.buildTime`.** Only a bundling step ever set it; the const diff --git a/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md b/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md index 87a3df7fa91..6ac8ee923bc 100644 --- a/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md +++ b/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md @@ -106,27 +106,28 @@ answered something other than what Excel documents. Each now matches the specification, so any of these that still reads like upstream's version is a regression rather than a port detail. -| Function | What it does now | -| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| `PROPER` | Capitalizes any letter following a non-letter, so `2-way` becomes `2-Way`. A word is a run of letters, not a run of non-spaces. | -| `TRIM` | Collapses and strips the ASCII space only, leaving tabs, newlines and U+00A0 to `CLEAN` and `SUBSTITUTE`. | -| `SEARCH` | Reads `find_text` as a wildcard pattern — `*`, `?`, and `~` escaping either — which is the other half of what separates it from `FIND`. Matched by `wildcard.ts` in one forward pass, not by a regex: `.*` per star makes a backtracking engine exponential, which turns a stray run of asterisks into a hung indexing worker. `COUNTIF`'s criteria go through the same matcher. | -| `SUBSTITUTE` (4-argument) | Counts an occurrence at position 0, so instance 1 is the first match wherever it sits. | -| `TEXT` | Renders date and time format codes rather than returning the bare serial. `mm` reads as minutes only where the clock puts it — after an hour run, or before a seconds run — and as the month everywhere else, `mmmmm` as the month's initial. Bracketed runs are colour, condition and locale codes and print nothing, except `[h]`/`[m]`/`[s]`, which are elapsed totals. | -| `NUMBERVALUE` | Ignores spaces anywhere in the text and divides by 100 per trailing percent sign, so `9%%` is 0.0009. | -| `CHAR` | Restricted to 1–255, the single-byte range. `UNICHAR` is the one that reaches past it. | -| `ISEVEN`, `ISODD` | Truncate toward zero before testing parity, so `ISEVEN(-2.5)` is true. | -| `WEEKDAY`, `WEEKNUM` | Honour every return type, including the 11–17 ladder that walks the start of the week forward and `WEEKNUM`'s ISO 21. Others raise `#NUM!`. | -| `ISOWEEKNUM`, `WEEKNUM(…, 21)` | Number from the week holding the year's first Thursday, so an early-January date reports the previous ISO year's 52nd or 53rd week. | -| `TIMEVALUE` | Reads a trailing meridiem, so an afternoon time is not twelve hours early. | -| `BASE`, `BIN2HEX`, `DEC2HEX`, `OCT2HEX` | Emit upper-case digits above 9. The reading side already accepts either casing, so a round trip survives. | -| `COMPLEX` and the `IM*` family | Drop the coefficient for an imaginary part of -1 as well as +1, so `COMPLEX(0, -1)` is the `-i` the parser already reads. | -| `ERF`, `ERFC` | Computed from the regularized incomplete gamma function to full double precision. `ERFC` is the upper tail, not `1 - ERF`, so the far tail keeps its digits. Both ends of the argument range are answered directly, since x² is what overflows and underflows first: erf saturates at ±1 above ~1.3e154 and is 2x/√π below ~1.5e-162. | -| `WEIBULL_DIST` | Takes alpha as the shape and beta as the scale, as Excel does. jstat's signature is `(x, scale, shape)`, so they cross at the call. | -| `T_TEST` | Pairs its Welch standard error with Welch–Satterthwaite degrees of freedom — Excel's two-tailed unequal-variance test, type 3. | -| `IRR`, `IRR_BY`, `XIRR` | Raise `#NUM!` unless the rate the search settled on is really a root — its net present value is zero, or the value changes sign across it. The second test is what recognizes a root near -100% or on a long series, where discounting amplifies rounding past any fixed residual. | -| `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD` | Raise `#NUM!` for a maturity more than a year past settlement. | -| `COUPDAYS` | Measures the real coupon period containing settlement under basis 1, actual/actual, with the schedule measured from maturity and sticky to month ends — a bond maturing on the last day of a month pays on the last day of every month. The other bases give every period the same nominal length, but all of them validate the dates. | +| Function | What it does now | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROPER` | Capitalizes any letter following a non-letter, so `2-way` becomes `2-Way`. A word is a run of letters, not a run of non-spaces. | +| `TRIM` | Collapses and strips the ASCII space only, leaving tabs, newlines and U+00A0 to `CLEAN` and `SUBSTITUTE`. | +| `SEARCH` | Reads `find_text` as a wildcard pattern — `*`, `?`, and `~` escaping either — which is the other half of what separates it from `FIND`. Matched by `wildcard.ts` in one forward pass, not by a regex: `.*` per star makes a backtracking engine exponential, which turns a stray run of asterisks into a hung indexing worker. `COUNTIF`'s criteria go through the same matcher. | +| `SUBSTITUTE` (4-argument) | Counts an occurrence at position 0, so instance 1 is the first match wherever it sits. | +| `TEXT` | Renders date and time format codes rather than returning the bare serial. `mm` reads as minutes only where the clock puts it — after an hour run, or before a seconds run — and as the month everywhere else, `mmmmm` as the month's initial. Bracketed runs are colour, condition and locale codes and print nothing, except `[h]`/`[m]`/`[s]`, which are elapsed totals. | +| `NUMBERVALUE` | Ignores spaces anywhere in the text and divides by 100 per trailing percent sign, so `9%%` is 0.0009. | +| `CHAR` | Restricted to 1–255, the single-byte range. `UNICHAR` is the one that reaches past it. | +| `ISEVEN`, `ISODD` | Truncate toward zero before testing parity, so `ISEVEN(-2.5)` is true. | +| `WEEKDAY`, `WEEKNUM` | Honour every return type, including the 11–17 ladder that walks the start of the week forward and `WEEKNUM`'s ISO 21. Others raise `#NUM!`. | +| `ISOWEEKNUM`, `WEEKNUM(…, 21)` | Number from the week holding the year's first Thursday, so an early-January date reports the previous ISO year's 52nd or 53rd week. | +| `TIMEVALUE` | Reads a trailing meridiem, so an afternoon time is not twelve hours early. | +| `BASE`, `BIN2HEX`, `DEC2HEX`, `OCT2HEX` | Emit upper-case digits above 9. The reading side already accepts either casing, so a round trip survives. | +| `COMPLEX` and the `IM*` family | Drop the coefficient for an imaginary part of -1 as well as +1, so `COMPLEX(0, -1)` is the `-i` the parser already reads. | +| `ERF`, `ERFC` | Computed from the regularized incomplete gamma function to full double precision. `ERFC` is the upper tail, not `1 - ERF`, so the far tail keeps its digits. Both ends of the argument range are answered directly, since x² is what overflows and underflows first: erf saturates at ±1 above ~1.3e154 and is 2x/√π below ~1.5e-162. | +| `WEIBULL_DIST` | Takes alpha as the shape and beta as the scale, as Excel does. jstat's signature is `(x, scale, shape)`, so they cross at the call. | +| `T_TEST` | Pairs its Welch standard error with Welch–Satterthwaite degrees of freedom — Excel's two-tailed unequal-variance test, type 3. | +| `IRR`, `IRR_BY`, `XIRR` | Raise `#NUM!` unless the rate the search settled on is really a root — its net present value is zero, or the value changes sign across it. The second test is what recognizes a root near -100% or on a long series, where discounting amplifies rounding past any fixed residual. | +| `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD` | Raise `#NUM!` for a maturity more than a year past settlement. | +| `COUPDAYS` | Measures the real coupon period containing settlement under basis 1, actual/actual, with the schedule measured from maturity and sticky to month ends — a bond maturing on the last day of a month pays on the last day of every month. The other bases give every period the same nominal length, but all of them validate the dates. | +| `YEARFRAC` (basis 4) | Counts the European 30/360, which pulls a day-31 back to the 30th at both ends of the span before differencing. Upstream leaves both endpoints where they stand, a raw 30/360, which counts a day too many whenever one end falls on the 31st and the other does not. Basis 0's US rule stays asymmetric by design: it reaches the end date only once the start has landed on the 30th. | `DAYS360`'s US/NASD method implements the day-31 rule but not the last-day-of-February rule Microsoft documents, which is left alone: Excel's own diff --git a/packages/bxl/src/formulajs/dateSerial.ts b/packages/bxl/src/formulajs/dateSerial.ts index 65bee3f4fb7..defb3a4a1cd 100644 --- a/packages/bxl/src/formulajs/dateSerial.ts +++ b/packages/bxl/src/formulajs/dateSerial.ts @@ -242,6 +242,12 @@ export function yearFrac( case 3: return daysBetween(startDate, endDate) / 365; case 4: + // The European 30/360 pulls a day-31 back to the 30th at both ends of + // the span, where basis 0 above only reaches the end date once the start + // has already landed on the 30th. It counts the same days as DAYS360's + // European method. + if (sd === 31) sd = 30; + if (ed === 31) ed = 30; return (ed + em * 30 + ey * 360 - (sd + sm * 30 + sy * 360)) / 360; default: throwExcelError(EXCEL_ERROR.num); diff --git a/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts b/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts index 099260a6315..5eb202b963c 100644 --- a/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts +++ b/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts @@ -125,6 +125,45 @@ export const formulaDateCases: CoverageCase[] = [ expected: 0.4958904109589, tolerance: 1e-12, }, + // Basis 4 is the European 30/360, which pulls a day-31 back to the 30th at + // both ends of the span before counting. Three spans separate it from a raw + // 30/360 that leaves the 31st where it stands — a day-31 start, a day-31 end, + // and one with both, where the two shifts cancel — and the day-31 end also + // separates it from basis 0, whose US rule reaches that end conditionally. + { + covers: 'YEARFRAC/3', + // 394 days from the 31st of July, where leaving that 31st where it stands + // would count 393. + source: 'YEARFRAC(DATE(2021, 7, 31), DATE(2022, 9, 4), 4)', + expected: 1.0944444444444446, + tolerance: 1e-12, + }, + { + covers: 'YEARFRAC/3', + // 61 days to the 31st of August, from a start earlier than the 30th. + source: 'YEARFRAC(DATE(2023, 6, 29), DATE(2023, 8, 31), 4)', + expected: 0.16944444444444445, + tolerance: 1e-12, + }, + { + covers: 'YEARFRAC/3', + // The same span on basis 0, the US 30/360, which is where the two + // conventions part company: it reaches the end date only once the start + // has landed on the 30th, so this August 31st stays where it is and the + // span counts 62 days. + source: 'YEARFRAC(DATE(2023, 6, 29), DATE(2023, 8, 31), 0)', + expected: 0.17222222222222222, + tolerance: 1e-12, + }, + { + covers: 'YEARFRAC/3', + // Day 31 at both ends: 210 days, the same answer a raw 30/360 gives, and + // one a convention that pulled back a single end would miss by a day in + // either direction. + source: 'YEARFRAC(DATE(2023, 1, 31), DATE(2023, 8, 31), 4)', + expected: 0.5833333333333334, + tolerance: 1e-12, + }, // Week fields. 2026-04-30 is a Thursday: return type 1, the default, // numbers from Sunday = 1, type 2 from Monday = 1. { covers: 'WEEKDAY/1', source: 'WEEKDAY(DATE(2026, 4, 30))', expected: 5 }, From 5b2618f4f436e0400d53ce20bc40cd074c924cfb Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 12:05:55 -0400 Subject: [PATCH 2/3] Read a YEARFRAC span the way Excel does, on every basis The earlier of the two dates opens the span whichever argument carried it, and a time of day is no part of a day count. Both reach past the sign: bases 0 and 1 read their endpoints asymmetrically, so on a reversed pair basis 0 pulled the wrong end onto the 30th and basis 1 averaged over a year count of zero, answering NaN. Basis 0 also gains the two February clauses it lacked. A span opening on the last day of February counts that February as a whole 30-day month, and closes the same way only when it ends on a February month end too. DISC and PRICEDISC raise #NUM! unless settlement precedes maturity, as the TBILL family already did, and parse their basis rather than coercing it, so a basis that is not a number is an error instead of a silent US 30/360. ACCRINT truncates its frequency and basis, as every other function taking them does. Four Excel-compatible references agree on all of it: LibreOffice's GetYearFrac, Apache POI's YearFracCalculator, ExcelFinancialFunctions' dateDiff360Eu, and David Wheeler's specification derived from Excel itself. Over 800 February-heavy spans across all five bases, none disagrees. Co-Authored-By: Claude Opus 5 (1M context) --- packages/bxl/CHANGELOG.md | 34 +++-- packages/bxl/src/formulajs/UPSTREAM-DIFFS.md | 60 +++++--- packages/bxl/src/formulajs/dateSerial.ts | 33 ++++- packages/bxl/src/formulajs/financial.ts | 41 ++++-- .../function-coverage/formula-date.ts | 139 +++++++++++++++--- .../function-coverage/formula-financial.ts | 40 +++++ 6 files changed, 280 insertions(+), 67 deletions(-) diff --git a/packages/bxl/CHANGELOG.md b/packages/bxl/CHANGELOG.md index 5e3d7a41800..65e3a38e8c9 100644 --- a/packages/bxl/CHANGELOG.md +++ b/packages/bxl/CHANGELOG.md @@ -62,9 +62,10 @@ versions may change syntax behavior until `1.0.0`. On the jq side: named captures now travel on a match, so `capture`, `sub` and `gsub` can read them, and a group that did not participate reports an absent capture rather than crashing; `round` ties away from zero; `isinfinite` - excludes NaN, and `isfinite` with it; `lgamma_r` returns its `[magnitude, -sign]` pair; `scalars_or_empty` keeps empty collections; `max_by` breaks ties - on the last maximum, as jq does; `inputs` yields an empty stream. On the Excel + excludes NaN, and `isfinite` with it; `lgamma_r` returns its + `[magnitude, sign]` pair; `scalars_or_empty` keeps empty collections; `max_by` + breaks ties on the last maximum, as jq does; `inputs` yields an empty stream. + On the Excel side: `PROPER`, `TRIM`, `SEARCH` (wildcards), `SUBSTITUTE` (an occurrence at position 0), `TEXT` (date format codes), `NUMBERVALUE` (percent signs and spaces), `CHAR` (bounded to 1–255), `ISEVEN`/`ISODD` (truncation), @@ -89,14 +90,25 @@ sign]` pair; `scalars_or_empty` keeps empty collections; `max_by` breaks ties single numbers in both readable and canonical-jq syntax, where the tokenizer previously ended the literal at the first digit and read the rest as a name. -- **`YEARFRAC` on basis 4 counts the European 30/360 it names.** The convention - pulls a day-31 back to the 30th at both ends of the span before differencing; - leaving both endpoints where they stand is a raw 30/360, which counted a day - too many whenever one end fell on the 31st and the other did not — small, but - always in the same direction. Basis 4 reaches `DISC`, `PRICEDISC` and - `ACCRINT` as well as `YEARFRAC` itself. Basis 0's US rule is a different - convention and keeps its own asymmetry, where the end date moves only once - the start has landed on the 30th. +- **`YEARFRAC` reads a span the way Excel does, on all five bases.** Basis 4 is + now the European 30/360 it names: a day-31 endpoint moves onto the 30th at + both ends before differencing, where leaving both where they stand is a raw + 30/360 that lands a day out in whichever direction the 31 sits — a day short + when the span opens on the 31st, a day long when it closes on one. Basis 0's US 30/360 counts a February that a span opens on as + a whole 30-day month, and closes the same way when the span also ends on the + last day of a February. The earlier of the two dates opens the span however + the arguments arrived, so a reversed pair measures a length rather than a + negative — and on basis 1 a reversed pair produced `NaN`, since the + multi-year branch averaged over a year count of zero. A time of day is no + part of a day count, so a serial carrying one names the same day as a serial + without one, which is how `NOW()` reaches these functions. Each of these + carries into `DISC`, `PRICEDISC` and `ACCRINT`, which take a basis and measure + their own spans with it. + +- **`DISC` and `PRICEDISC` raise `#NUM!` unless settlement precedes maturity**, + as the `TBILL` family already did. A transposed pair used to answer a negative + discount or a price above redemption, and settling on the maturity date + divided by a term of zero. ### Removed diff --git a/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md b/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md index 6ac8ee923bc..0d00a7709cb 100644 --- a/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md +++ b/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md @@ -106,33 +106,47 @@ answered something other than what Excel documents. Each now matches the specification, so any of these that still reads like upstream's version is a regression rather than a port detail. -| Function | What it does now | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `PROPER` | Capitalizes any letter following a non-letter, so `2-way` becomes `2-Way`. A word is a run of letters, not a run of non-spaces. | -| `TRIM` | Collapses and strips the ASCII space only, leaving tabs, newlines and U+00A0 to `CLEAN` and `SUBSTITUTE`. | -| `SEARCH` | Reads `find_text` as a wildcard pattern — `*`, `?`, and `~` escaping either — which is the other half of what separates it from `FIND`. Matched by `wildcard.ts` in one forward pass, not by a regex: `.*` per star makes a backtracking engine exponential, which turns a stray run of asterisks into a hung indexing worker. `COUNTIF`'s criteria go through the same matcher. | -| `SUBSTITUTE` (4-argument) | Counts an occurrence at position 0, so instance 1 is the first match wherever it sits. | -| `TEXT` | Renders date and time format codes rather than returning the bare serial. `mm` reads as minutes only where the clock puts it — after an hour run, or before a seconds run — and as the month everywhere else, `mmmmm` as the month's initial. Bracketed runs are colour, condition and locale codes and print nothing, except `[h]`/`[m]`/`[s]`, which are elapsed totals. | -| `NUMBERVALUE` | Ignores spaces anywhere in the text and divides by 100 per trailing percent sign, so `9%%` is 0.0009. | -| `CHAR` | Restricted to 1–255, the single-byte range. `UNICHAR` is the one that reaches past it. | -| `ISEVEN`, `ISODD` | Truncate toward zero before testing parity, so `ISEVEN(-2.5)` is true. | -| `WEEKDAY`, `WEEKNUM` | Honour every return type, including the 11–17 ladder that walks the start of the week forward and `WEEKNUM`'s ISO 21. Others raise `#NUM!`. | -| `ISOWEEKNUM`, `WEEKNUM(…, 21)` | Number from the week holding the year's first Thursday, so an early-January date reports the previous ISO year's 52nd or 53rd week. | -| `TIMEVALUE` | Reads a trailing meridiem, so an afternoon time is not twelve hours early. | -| `BASE`, `BIN2HEX`, `DEC2HEX`, `OCT2HEX` | Emit upper-case digits above 9. The reading side already accepts either casing, so a round trip survives. | -| `COMPLEX` and the `IM*` family | Drop the coefficient for an imaginary part of -1 as well as +1, so `COMPLEX(0, -1)` is the `-i` the parser already reads. | -| `ERF`, `ERFC` | Computed from the regularized incomplete gamma function to full double precision. `ERFC` is the upper tail, not `1 - ERF`, so the far tail keeps its digits. Both ends of the argument range are answered directly, since x² is what overflows and underflows first: erf saturates at ±1 above ~1.3e154 and is 2x/√π below ~1.5e-162. | -| `WEIBULL_DIST` | Takes alpha as the shape and beta as the scale, as Excel does. jstat's signature is `(x, scale, shape)`, so they cross at the call. | -| `T_TEST` | Pairs its Welch standard error with Welch–Satterthwaite degrees of freedom — Excel's two-tailed unequal-variance test, type 3. | -| `IRR`, `IRR_BY`, `XIRR` | Raise `#NUM!` unless the rate the search settled on is really a root — its net present value is zero, or the value changes sign across it. The second test is what recognizes a root near -100% or on a long series, where discounting amplifies rounding past any fixed residual. | -| `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD` | Raise `#NUM!` for a maturity more than a year past settlement. | -| `COUPDAYS` | Measures the real coupon period containing settlement under basis 1, actual/actual, with the schedule measured from maturity and sticky to month ends — a bond maturing on the last day of a month pays on the last day of every month. The other bases give every period the same nominal length, but all of them validate the dates. | -| `YEARFRAC` (basis 4) | Counts the European 30/360, which pulls a day-31 back to the 30th at both ends of the span before differencing. Upstream leaves both endpoints where they stand, a raw 30/360, which counts a day too many whenever one end falls on the 31st and the other does not. Basis 0's US rule stays asymmetric by design: it reaches the end date only once the start has landed on the 30th. | +| Function | What it does now | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PROPER` | Capitalizes any letter following a non-letter, so `2-way` becomes `2-Way`. A word is a run of letters, not a run of non-spaces. | +| `TRIM` | Collapses and strips the ASCII space only, leaving tabs, newlines and U+00A0 to `CLEAN` and `SUBSTITUTE`. | +| `SEARCH` | Reads `find_text` as a wildcard pattern — `*`, `?`, and `~` escaping either — which is the other half of what separates it from `FIND`. Matched by `wildcard.ts` in one forward pass, not by a regex: `.*` per star makes a backtracking engine exponential, which turns a stray run of asterisks into a hung indexing worker. `COUNTIF`'s criteria go through the same matcher. | +| `SUBSTITUTE` (4-argument) | Counts an occurrence at position 0, so instance 1 is the first match wherever it sits. | +| `TEXT` | Renders date and time format codes rather than returning the bare serial. `mm` reads as minutes only where the clock puts it — after an hour run, or before a seconds run — and as the month everywhere else, `mmmmm` as the month's initial. Bracketed runs are colour, condition and locale codes and print nothing, except `[h]`/`[m]`/`[s]`, which are elapsed totals. | +| `NUMBERVALUE` | Ignores spaces anywhere in the text and divides by 100 per trailing percent sign, so `9%%` is 0.0009. | +| `CHAR` | Restricted to 1–255, the single-byte range. `UNICHAR` is the one that reaches past it. | +| `ISEVEN`, `ISODD` | Truncate toward zero before testing parity, so `ISEVEN(-2.5)` is true. | +| `WEEKDAY`, `WEEKNUM` | Honour every return type, including the 11–17 ladder that walks the start of the week forward and `WEEKNUM`'s ISO 21. Others raise `#NUM!`. | +| `ISOWEEKNUM`, `WEEKNUM(…, 21)` | Number from the week holding the year's first Thursday, so an early-January date reports the previous ISO year's 52nd or 53rd week. | +| `TIMEVALUE` | Reads a trailing meridiem, so an afternoon time is not twelve hours early. | +| `BASE`, `BIN2HEX`, `DEC2HEX`, `OCT2HEX` | Emit upper-case digits above 9. The reading side already accepts either casing, so a round trip survives. | +| `COMPLEX` and the `IM*` family | Drop the coefficient for an imaginary part of -1 as well as +1, so `COMPLEX(0, -1)` is the `-i` the parser already reads. | +| `ERF`, `ERFC` | Computed from the regularized incomplete gamma function to full double precision. `ERFC` is the upper tail, not `1 - ERF`, so the far tail keeps its digits. Both ends of the argument range are answered directly, since x² is what overflows and underflows first: erf saturates at ±1 above ~1.3e154 and is 2x/√π below ~1.5e-162. | +| `WEIBULL_DIST` | Takes alpha as the shape and beta as the scale, as Excel does. jstat's signature is `(x, scale, shape)`, so they cross at the call. | +| `T_TEST` | Pairs its Welch standard error with Welch–Satterthwaite degrees of freedom — Excel's two-tailed unequal-variance test, type 3. | +| `IRR`, `IRR_BY`, `XIRR` | Raise `#NUM!` unless the rate the search settled on is really a root — its net present value is zero, or the value changes sign across it. The second test is what recognizes a root near -100% or on a long series, where discounting amplifies rounding past any fixed residual. | +| `TBILLEQ`, `TBILLPRICE`, `TBILLYIELD` | Raise `#NUM!` for a maturity more than a year past settlement. | +| `COUPDAYS` | Measures the real coupon period containing settlement under basis 1, actual/actual, with the schedule measured from maturity and sticky to month ends — a bond maturing on the last day of a month pays on the last day of every month. The other bases give every period the same nominal length, but all of them validate the dates. | +| `YEARFRAC` | Reads a span the way Excel does on every basis. The earlier of the two dates opens it whichever argument carried it, and a time of day is no part of a day count. Basis 0's US 30/360 counts a February a span opens on as a whole 30-day month, and closes the same way only when the span also ends on the last day of a February. Basis 4 is the European 30/360, which moves a day-31 endpoint onto the 30th at both ends. Upstream reads the two arguments in the order given — a reversed span answers a negative fraction, and `NaN` on basis 1 — leaves both endpoints where they stand on basis 4, and has no February rule at all. | +| `DISC`, `PRICEDISC` | Raise `#NUM!` unless settlement precedes maturity, as the `TBILL` family does. A year fraction measures a length rather than a direction, so it cannot report a transposed pair on their behalf. | `DAYS360`'s US/NASD method implements the day-31 rule but not the last-day-of-February rule Microsoft documents, which is left alone: Excel's own shipped behavior is known to diverge from that doc text, so matching the text -would mean diverging from Excel. +would mean diverging from Excel. `YEARFRAC`'s basis 0 does apply a February +rule, which is not a contradiction — the two are separately observed functions. +LibreOffice draws the same line, omitting the February clauses from its +`DAYS360` while applying them in its `YEARFRAC`, and its analysis add-in's third +30/360 count reads a February start a third way again. Three implementations of +one nominal convention, so a change to any of them settles nothing about the +others. + +Reading these day counts off LibreOffice is worth one caution: on basis 4 its +`getDisc` divides by the clamped `GetYearFrac` while its `getPricedisc` and +`getAccrint` multiply by the unclamped `GetYearDiff`, so one nominal basis means +two different day counts inside one file. A differential will show `PRICEDISC` +and `ACCRINT` parting from it on basis 4 for that reason. `fsprojects/ExcelFinancialFunctions` +counts with its clamped `dateDiff360Eu` throughout, which is the shape here. ## Bringing in new upstream functions diff --git a/packages/bxl/src/formulajs/dateSerial.ts b/packages/bxl/src/formulajs/dateSerial.ts index defb3a4a1cd..5fb0276e265 100644 --- a/packages/bxl/src/formulajs/dateSerial.ts +++ b/packages/bxl/src/formulajs/dateSerial.ts @@ -172,13 +172,31 @@ function isLeapYear(year: number) { return new Date(Date.UTC(year, 1, 29)).getUTCMonth() === 1; } +/** + * Midnight on the day `date` falls in. A day count reads calendar days, so a + * serial carrying a time of day names the same day as the serial without one. + */ +export function startOfDay(date: Date) { + const day = new Date(date.getTime()); + day.setUTCHours(0, 0, 0, 0); + return day; +} + export function yearFrac( startLike: unknown, endLike: unknown, basisValue = 0, ): number { - const startDate = parseExcelDate(startLike); - const endDate = parseExcelDate(endLike); + // A year fraction is how long a span is, so the earlier date opens it + // whichever argument carried it. Bases 0 and 1 read their two endpoints + // asymmetrically, which makes this the count itself rather than only its + // sign: the day that gets pulled onto the 30th, and the year whose length + // divides, both follow from which date is the earlier one. + const firstDate = startOfDay(parseExcelDate(startLike)); + const secondDate = startOfDay(parseExcelDate(endLike)); + const reversed = secondDate.getTime() < firstDate.getTime(); + const startDate = reversed ? secondDate : firstDate; + const endDate = reversed ? firstDate : secondDate; const basis = Math.trunc(Number(basisValue) || 0); let sd = startDate.getUTCDate(); @@ -190,6 +208,12 @@ export function yearFrac( switch (basis) { case 0: + // The US 30/360 moves an endpoint onto the 30th under four conditions, + // each ruling out the ones below it. A day-31 start always moves. A + // day-31 end moves only from a start already on the 30th, which is what + // leaves the 31st standing under any earlier start. And a span opening on + // the last day of February counts that February as a whole 30-day month, + // closing the same way only when it ends on a February month end too. if (sd === 31 && ed === 31) { sd = 30; ed = 30; @@ -197,6 +221,11 @@ export function yearFrac( sd = 30; } else if (sd === 30 && ed === 31) { ed = 30; + } else if (sm === 2 && sd === (isLeapYear(sy) ? 29 : 28)) { + sd = 30; + if (em === 2 && ed === (isLeapYear(ey) ? 29 : 28)) { + ed = 30; + } } return (ed + em * 30 + ey * 360 - (sd + sm * 30 + sy * 360)) / 360; case 1: { diff --git a/packages/bxl/src/formulajs/financial.ts b/packages/bxl/src/formulajs/financial.ts index 2cce087f988..3393c8146a8 100644 --- a/packages/bxl/src/formulajs/financial.ts +++ b/packages/bxl/src/formulajs/financial.ts @@ -3,6 +3,7 @@ import { daysBetween, parseExcelDate, parseExcelDateArray, + startOfDay, yearFrac, } from './dateSerial.ts'; import { @@ -568,8 +569,10 @@ export function excelAccrint( const settlement = parseExcelDate(settlementLike); const rate = parseExcelNumber(rateLike); const par = parseExcelNumber(parLike); - const frequency = parseExcelNumber(frequencyLike); - const basis = parseExcelNumber(basisLike); + // Truncated, as every other function taking these reads them: a fractional + // frequency or basis names the whole one it sits on rather than nothing. + const frequency = Math.trunc(parseExcelNumber(frequencyLike)); + const basis = Math.trunc(parseExcelNumber(basisLike)); if (![1, 2, 4].includes(frequency)) { throwExcelError(EXCEL_ERROR.num); @@ -758,14 +761,25 @@ export function excelDisc( redemptionLike: unknown, basisLike: unknown = 0, ) { - // Called for validation only — parseExcelDate throws on a malformed date. - parseExcelDate(settlementLike); - parseExcelDate(maturityLike); + const settlement = startOfDay(parseExcelDate(settlementLike)); + const maturity = startOfDay(parseExcelDate(maturityLike)); const pr = parseExcelNumber(prLike); const redemption = parseExcelNumber(redemptionLike); - if (pr <= 0 || redemption <= 0) throwExcelError(EXCEL_ERROR.num); - const yf = yearFrac(settlementLike, maturityLike, Number(basisLike)); - return (redemption - pr) / redemption / yf; + // Parsed rather than coerced: a basis that is not a number is an error, where + // coercion would read it as the default and answer a US 30/360 for it. + const basis = parseExcelNumber(basisLike); + // A bill has to mature after it settles, and that span is the term the + // discount is quoted over. A year fraction measures how long a span is rather + // than which way it runs, so it cannot report a transposed pair on behalf of + // the functions that divide by it. + if (pr <= 0 || redemption <= 0 || settlement >= maturity) { + throwExcelError(EXCEL_ERROR.num); + } + return ( + (redemption - pr) / + redemption / + yearFrac(settlementLike, maturityLike, basis) + ); } export function excelPricedisc( @@ -775,10 +789,17 @@ export function excelPricedisc( redemptionLike: unknown, basisLike: unknown = 0, ) { + const settlement = startOfDay(parseExcelDate(settlementLike)); + const maturity = startOfDay(parseExcelDate(maturityLike)); const disc = parseExcelNumber(discLike); const redemption = parseExcelNumber(redemptionLike); - const yf = yearFrac(settlementLike, maturityLike, Number(basisLike)); - return redemption * (1 - disc * yf); + const basis = parseExcelNumber(basisLike); + if (settlement >= maturity) { + throwExcelError(EXCEL_ERROR.num); + } + return ( + redemption * (1 - disc * yearFrac(settlementLike, maturityLike, basis)) + ); } /** The last day of the month `date` falls in. */ diff --git a/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts b/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts index 5eb202b963c..0fd2c3f3445 100644 --- a/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts +++ b/packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts @@ -126,43 +126,140 @@ export const formulaDateCases: CoverageCase[] = [ tolerance: 1e-12, }, // Basis 4 is the European 30/360, which pulls a day-31 back to the 30th at - // both ends of the span before counting. Three spans separate it from a raw - // 30/360 that leaves the 31st where it stands — a day-31 start, a day-31 end, - // and one with both, where the two shifts cancel — and the day-31 end also - // separates it from basis 0, whose US rule reaches that end conditionally. + // both ends of the span before counting. A day-31 start and a day-31 end each + // separate it from a raw 30/360 that leaves the 31st alone, and they separate + // in opposite directions: pulling the start back lengthens the span, pulling + // the end back shortens it. Day counts are written as their own arithmetic + // here, since the fraction they divide into is what the convention is about. { covers: 'YEARFRAC/3', - // 394 days from the 31st of July, where leaving that 31st where it stands - // would count 393. + // A day-31 start: 394 days, where leaving the 31st of July in place counts + // one fewer. source: 'YEARFRAC(DATE(2021, 7, 31), DATE(2022, 9, 4), 4)', - expected: 1.0944444444444446, - tolerance: 1e-12, + expected: 394 / 360, }, { covers: 'YEARFRAC/3', - // 61 days to the 31st of August, from a start earlier than the 30th. + // A day-31 end, from a start earlier than the 30th: 61 days, where leaving + // the 31st of August in place counts one more. source: 'YEARFRAC(DATE(2023, 6, 29), DATE(2023, 8, 31), 4)', - expected: 0.16944444444444445, - tolerance: 1e-12, + expected: 61 / 360, + }, + { + covers: 'DAYS360/3', + // The same span and the same 61 days read through DAYS360's European + // method, which counts by the same rule. The rule is written out in both + // functions, so each answer is pinned here rather than derived from the + // other. + source: 'DAYS360(DATE(2023, 6, 29), DATE(2023, 8, 31), true)', + expected: 61, }, { covers: 'YEARFRAC/3', // The same span on basis 0, the US 30/360, which is where the two - // conventions part company: it reaches the end date only once the start - // has landed on the 30th, so this August 31st stays where it is and the - // span counts 62 days. + // conventions part company: it reaches the end date only once the start has + // landed on the 30th, so this August 31st stays where it is and the span + // counts 62 days. source: 'YEARFRAC(DATE(2023, 6, 29), DATE(2023, 8, 31), 0)', - expected: 0.17222222222222222, - tolerance: 1e-12, + expected: 62 / 360, }, { covers: 'YEARFRAC/3', - // Day 31 at both ends: 210 days, the same answer a raw 30/360 gives, and - // one a convention that pulled back a single end would miss by a day in - // either direction. + // Day 31 at both ends, where the two shifts cancel: 210 days, the answer a + // raw 30/360 gives too, and one a convention reaching a single end would + // miss by a day in either direction. source: 'YEARFRAC(DATE(2023, 1, 31), DATE(2023, 8, 31), 4)', - expected: 0.5833333333333334, - tolerance: 1e-12, + expected: 210 / 360, + }, + { + covers: 'YEARFRAC/3', + // The rule reads the day number and not the end of the month: February's + // 28th is the last day of its month and still counts as a 28th, so this + // span is 32 days where a month-end reading would make it 31. + source: 'YEARFRAC(DATE(2023, 2, 28), DATE(2023, 3, 31), 4)', + expected: 32 / 360, + }, + { + covers: 'YEARFRAC/3', + // Both ends on a month end and neither on a 31st — a leap February's 29th + // and an April 30th — so neither moves, for 61 days. + source: 'YEARFRAC(DATE(2024, 2, 29), DATE(2024, 4, 30), 4)', + expected: 61 / 360, + }, + // Basis 0 has two rules beyond the day-31 ones, and both are about February: + // a span opening on the last day of February counts that February as a whole + // 30-day month, and closes the same way only when it also ends on the last + // day of a February. + { + covers: 'YEARFRAC/3', + // 552 days from the last day of February, where counting that February at + // its own 28 days gives 554. + source: 'YEARFRAC(DATE(2025, 2, 28), DATE(2026, 9, 12), 0)', + expected: 552 / 360, + }, + { + covers: 'YEARFRAC/3', + // Both ends on the last day of February, the later one a leap year's 29th: + // exactly a year, since each February counts whole. + source: 'YEARFRAC(DATE(2023, 2, 28), DATE(2024, 2, 29), 0)', + expected: 1, + }, + { + covers: 'YEARFRAC/3', + // A February start reaches its own month end and no further: the 31st of + // March closing the span stays a 31st, for 31 days. + source: 'YEARFRAC(DATE(2023, 2, 28), DATE(2023, 3, 31), 0)', + expected: 31 / 360, + }, + { + covers: 'YEARFRAC/3', + // The end date's February rule waits on the start's: closing on the last + // day of February from an ordinary start counts 43 days, not the 45 a whole + // February would give. + source: 'YEARFRAC(DATE(2023, 1, 15), DATE(2023, 2, 28), 0)', + expected: 43 / 360, + }, + // A year fraction is how long a span is, so the earlier date opens it however + // the arguments arrived. On the bases that read their two endpoints + // asymmetrically that decides the count and not merely its sign. + { + covers: 'YEARFRAC/3', + // Reversed, on basis 0: 510 days from the 1st of September to the 31st of + // January. Taken in argument order that January 31st would be the start + // that moves onto the 30th, costing the span a day it does not lose. + source: 'YEARFRAC(DATE(2021, 1, 31), DATE(2019, 9, 1), 0)', + expected: 510 / 360, + }, + { + covers: 'YEARFRAC/3', + // Reversed, on basis 1: 335 days over a 365-day year. The year length + // follows from where the span begins, and taken in argument order this pair + // reaches the multi-year branch and averages over a year count of zero, + // which is a division by zero rather than a wrong answer. + source: 'YEARFRAC(DATE(2019, 5, 30), DATE(2018, 6, 29), 1)', + expected: 335 / 365, + }, + { + covers: 'YEARFRAC/3', + // Reversed, on basis 4, whose rule reaches both ends alike so that only the + // sign is ever in question: 210 days, and a length is positive. + source: 'YEARFRAC(DATE(2023, 8, 31), DATE(2023, 1, 31), 4)', + expected: 210 / 360, + }, + // A day count reads calendar days, so a time of day is no part of one — which + // is how `NOW()` reaches these functions. + { + covers: 'YEARFRAC/3', + // One day over a 365-day year, not the two that carrying a day and a half + // up to the next whole day would give. + source: 'YEARFRAC(DATE(2023, 1, 1), DATE(2023, 1, 2) + TIME(12, 0, 0), 1)', + expected: 1 / 365, + }, + { + covers: 'YEARFRAC/3', + // Both ends on the same day: no span at all, whatever the clock says. + source: 'YEARFRAC(DATE(2023, 1, 1) + TIME(18, 0, 0), DATE(2023, 1, 1), 1)', + expected: 0, }, // Week fields. 2026-04-30 is a Thursday: return type 1, the default, // numbers from Sunday = 1, type 2 from Monday = 1. diff --git a/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts b/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts index 363ae7e459e..b6ed4b6fd30 100644 --- a/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts +++ b/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts @@ -474,6 +474,46 @@ export const formulaFinancialCases: CoverageCase[] = [ expected: 100 * (1 - 0.05 * (181 / 365)), tolerance: 1e-9, }, + // The bond functions read their numeric arguments the way the rest of the + // package reads them. A basis that is not a number is an error rather than + // the default it would coerce to, and a fractional basis or frequency names + // the whole one it sits on rather than nothing at all. + { + covers: 'DISC/5', + source: 'DISC("2023-01-01", "2023-07-01", 97.5, 100, "x")', + throws: /#VALUE!/, + }, + { + covers: 'PRICEDISC/5', + source: 'PRICEDISC("2023-01-01", "2023-07-01", 0.05, 100, "x")', + throws: /#VALUE!/, + }, + { + covers: 'ACCRINT/7', + // Frequency 2.9 is semi-annual and basis 4.9 is the European 30/360, where + // reading either whole number off the fraction is the difference between an + // answer and a #NUM!. Accruing 75 30/360 days of a 10% coupon on 1000 par: + // the European rule pulls the January 31st issue onto the 30th. + source: + 'ACCRINT("2023-01-31", "2023-07-01", "2023-04-15", 0.1, 1000, 2.9, 4.9)', + expected: 1000 * 0.1 * (75 / 360), + }, + // Settlement has to fall before maturity, which is the term these price over. + // A year fraction measures how long a span is rather than which way it runs, + // so it cannot report a transposed pair on their behalf. + { + covers: 'DISC/4', + // Transposed, where the discount would otherwise come back negative. + source: 'DISC("2023-07-01", "2023-01-01", 97.5, 100)', + throws: /#NUM!/, + }, + { + covers: 'PRICEDISC/4', + // Settling on the maturity date leaves no term to discount over, and a + // price for it would be redemption at face value. + source: 'PRICEDISC("2023-01-01", "2023-01-01", 0.05, 100)', + throws: /#NUM!/, + }, { covers: 'TBILLEQ/3', source: 'TBILLEQ("2023-01-01", "2023-04-01", 0.04)', From 92b1a3ce44f21e101925e92a8d8fbf6d91712a6e Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Tue, 18 Aug 2026 16:06:02 -0400 Subject: [PATCH 3/3] Count basis 4 through the shared 30/360 helper The European 30/360 is `DAYS360`'s European method exactly, so basis 4 counts through the same helper rather than carrying a second copy of the clamp. Basis 0 keeps its own ladder: it reads the bond schedule's rules, including the February clauses `DAYS360` does not carry. Co-Authored-By: Claude Opus 5 (1M context) --- packages/bxl/src/formulajs/dateSerial.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/bxl/src/formulajs/dateSerial.ts b/packages/bxl/src/formulajs/dateSerial.ts index 606eab46964..d325ae35294 100644 --- a/packages/bxl/src/formulajs/dateSerial.ts +++ b/packages/bxl/src/formulajs/dateSerial.ts @@ -214,6 +214,8 @@ export function yearFrac( // leaves the 31st standing under any earlier start. And a span opening on // the last day of February counts that February as a whole 30-day month, // closing the same way only when it ends on a February month end too. + // `couponDays360` in `financial.ts` reads a bond schedule by these same + // rules; `DAYS360` carries the day-31 pair alone. if (sd === 31 && ed === 31) { sd = 30; ed = 30; @@ -271,13 +273,11 @@ export function yearFrac( case 3: return daysBetween(startDate, endDate) / 365; case 4: - // The European 30/360 pulls a day-31 back to the 30th at both ends of - // the span, where basis 0 above only reaches the end date once the start - // has already landed on the 30th. It counts the same days as DAYS360's - // European method. - if (sd === 31) sd = 30; - if (ed === 31) ed = 30; - return (ed + em * 30 + ey * 360 - (sd + sm * 30 + sy * 360)) / 360; + // The European 30/360 pulls a day-31 back to the 30th at both ends of the + // span, where basis 0 above reaches the end date only once the start has + // landed on the 30th. That is `DAYS360`'s European method exactly, so it + // is counted by the same code rather than by a second copy of the rule. + return days360(startDate, endDate, true) / 360; default: throwExcelError(EXCEL_ERROR.num); }