diff --git a/packages/bxl/CHANGELOG.md b/packages/bxl/CHANGELOG.md index 2f42f293263..10ac18a66db 100644 --- a/packages/bxl/CHANGELOG.md +++ b/packages/bxl/CHANGELOG.md @@ -114,6 +114,29 @@ versions may change syntax behavior until `1.0.0`. alone, since Excel's shipped `DAYS360` parts from the February rules its own documentation gives while Excel's bond functions apply them. +- **`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. + `DISC` and `PRICEDISC` divide by this fraction and inherit all four; `ACCRINT` + counts its own schedule and is reached by none of them. + +- **`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. Both also 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, and + `ACCRINT` truncates its frequency and basis as every other function taking + them does. + ### 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 3c03f410921..fd7db493fe3 100644 --- a/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md +++ b/packages/bxl/src/formulajs/UPSTREAM-DIFFS.md @@ -128,13 +128,27 @@ regression rather than a port detail. | `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. | | `ACCRINT` | Counts the quasi-coupon periods the holding touches on the schedule `first_interest` anchors, on every basis: periods covered whole each earn one coupon, the period the holding opens in earns the share of its own length it covers, and settlement's distance from a reference coupon date is a signed share of one period. Upstream answers `par * rate * YEARFRAC(issue, settlement)` and never reads `first_interest`. The two coincide wherever every period the holding touches measures its nominal `year / frequency`, and part where a period's own day count differs. Its 30/360 also reads a February month end as the 30th, which `DAYS360` does not. | +| `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. Excel's bond functions do apply those clauses, -so `ACCRINT` counts with its own 30/360 rather than sharing `DAYS360`'s — the two -readings are separately observed and deliberately not one implementation. +so `ACCRINT` counts with its own 30/360 rather than sharing `DAYS360`'s, and +`YEARFRAC`'s basis 0 applies them as well — three readings of one nominal +convention, separately observed and deliberately not one implementation. +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. + +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 01937b0329d..d325ae35294 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,14 @@ 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. + // `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; @@ -197,6 +223,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: { @@ -242,7 +273,11 @@ export function yearFrac( case 3: return daysBetween(startDate, endDate) / 365; case 4: - 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); } diff --git a/packages/bxl/src/formulajs/financial.ts b/packages/bxl/src/formulajs/financial.ts index e81d666658c..bfda3f05ded 100644 --- a/packages/bxl/src/formulajs/financial.ts +++ b/packages/bxl/src/formulajs/financial.ts @@ -4,6 +4,7 @@ import { daysBetween, parseExcelDate, parseExcelDateArray, + startOfDay, yearFrac, } from './dateSerial.ts'; import { @@ -579,8 +580,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); @@ -773,14 +776,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( @@ -790,10 +804,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 099260a6315..0fd2c3f3445 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,142 @@ 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. 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', + // 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: 394 / 360, + }, + { + covers: 'YEARFRAC/3', + // 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: 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. + source: 'YEARFRAC(DATE(2023, 6, 29), DATE(2023, 8, 31), 0)', + expected: 62 / 360, + }, + { + covers: 'YEARFRAC/3', + // 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: 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. { covers: 'WEEKDAY/1', source: 'WEEKDAY(DATE(2026, 4, 30))', expected: 5 }, 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 4f3f0be4957..801cf76bb2a 100644 --- a/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts +++ b/packages/bxl/tests/unit/fixtures/function-coverage/formula-financial.ts @@ -706,6 +706,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)',