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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/bxl/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions packages/bxl/src/formulajs/UPSTREAM-DIFFS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 38 additions & 3 deletions packages/bxl/src/formulajs/dateSerial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -190,13 +208,26 @@ 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;
} else if (sd === 31) {
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: {
Expand Down Expand Up @@ -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);
}
Expand Down
41 changes: 31 additions & 10 deletions packages/bxl/src/formulajs/financial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
daysBetween,
parseExcelDate,
parseExcelDateArray,
startOfDay,
yearFrac,
} from './dateSerial.ts';
import {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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. */
Expand Down
136 changes: 136 additions & 0 deletions packages/bxl/tests/unit/fixtures/function-coverage/formula-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading