Skip to content

Add COUPDAYBS, COUPDAYS, COUPDAYSNC, COUPNCD, COUPNUM and COUPPCD functions - #1804

Open
Tobiadefami wants to merge 9 commits into
developfrom
feature/HF-373
Open

Tobiadefami wants to merge 9 commits into
developfrom
feature/HF-373

Conversation

@Tobiadefami

@Tobiadefami Tobiadefami commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Context

This PR adds the six Excel bond coupon functions: COUPPCD (previous coupon date), COUPNCD (next coupon date),
COUPNUM (number of coupons), COUPDAYBS (days from the start of the coupon period to settlement), COUPDAYSNC
(days from settlement to the next coupon date) and COUPDAYS (days in the coupon period).

Important

Stacked on #1799 (ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, YIELDDISC). The coupon functions
reuse that PR's day-count helper (DateTimeHelper.dayCountByBasis) and its argument helpers (strictNumber,
coerceToSecurityDate, coerceToDayCountBasis), so this branch is based on feature/HF-454. Until #1799 merges,
this diff also shows its commits. The changes in this PR are the last three commits:
feat(HF-373): add the coupon schedule and COUPPCD, COUPNCD and COUPNUM,
feat(HF-373): add COUPDAYBS, COUPDAYSNC and COUPDAYS and
docs(HF-373): link the pull request in the changelog. Once #1799 merges, this branch will be rebased onto
develop and only those three commits will remain.

Behavior, checked against Excel:

  • Coupon schedule. Coupon dates step back from maturity by 12 / frequency months, each computed from
    maturity itself, so the day never drifts. If maturity is the last day of its month, every coupon date is the
    last day of its month (28 February counts only in non-leap years). Otherwise maturity's day is kept, clamped to
    the month length with leap years: maturity 30 August 2012, semiannual, gives 29 February 2012 and then 30 August
    2011. COUPPCD is the last coupon date on or before settlement (settlement on a coupon date returns it), COUPNCD
    the next one, and COUPNUM the number of coupon dates after settlement up to and including maturity. basis is
    validated but does not change these three.
  • Validation order: settlement, maturity, settlement before maturity, empty arguments, frequency, then
    basis. Unlike Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799's functions, basis is checked last: COUPPCD(s, m, 3, "abc") is #NUM!, not #VALUE!.
  • Empty arguments. An empty settlement, maturity or frequency argument, as in COUPPCD(s, m, , 1), gives
    #N/A. settlement after maturity still gives #NUM! first. A blank cell is not an empty argument: it is 0, so a
    blank frequency cell gives #NUM!.
  • Coercion, as in Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799: booleans and the empty text give #VALUE!; a blank cell is 0; numeric, percent,
    currency and date text are accepted (date text per the dateFormats config). Dates are truncated, and dates outside
    the supported range give #NUM!; a negative date such as −0.5 is #NUM!, before any later argument. frequency is truncated (4.9 → 4) and must then be 1, 2 or 4; basis is
    truncated, but a negative value such as −0.5 is #NUM!. A range is #VALUE!; array constants are lifted with
    useArrayArithmetic: true.
  • COUPDAYBS: basis 0 counts with the US 30/360 rule (YEARFRAC's), basis 4 with the European rule, bases 1–3 in
    actual days.
  • COUPDAYSNC: bases 1–4 count directly from settlement to the next coupon date. Basis 0 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:
    COUPDAYSNC(DATE(2011,3,1), DATE(2011,8,29), 2, 0) is 178 (period 28 February to 29 August counts 179 days, minus 1).
  • COUPDAYS: 360 / frequency for bases 0, 2 and 4, and 365 / frequency for basis 3 (182.5, 91.25). For basis 1
    it is Excel's own period length, which is not always the next coupon date minus the previous one when the maturity
    falls on the 28th to the 31st: walking back from maturity, Excel keeps each date's day clamped to the previous
    date's (so 31 March, 30 September, 30 March), then steps back once with the end-of-month rule.
    COUPDAYS(DATE(2014,11,28), DATE(2015,5,29), 4, 1) is 91, while COUPNCD − COUPPCD is 92.
  • So COUPDAYBS + COUPDAYSNC is not always COUPDAYS, in Excel and here.
  • Dates before 1 March 1900: HyperFormula's date serials differ from Excel's there, as for every date function. A
    previous coupon date before the earliest supported date gives #NUM!.

Note: Microsoft's pages for these functions are wrong in three places, and this implementation follows Excel:

  • "All arguments are truncated to integers": negative fractions are not truncated to 0; a basis or settlement of
    −0.5 gives #NUM!.
  • "If settlement or maturity is not a valid date, … returns the #VALUE! error value": only text, booleans and the
    empty text give #VALUE!; numbers outside the date range give #NUM!.
  • The COUPPCD and COUPNCD examples show the result as a date; Excel returns a plain serial number, in a General
    cell. These functions set no date number format.

Implementation

  • Everything lives in FinancialPlugin, next to Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799's securities functions: six methods, one runner
    (couponFunction) that validates the arguments and builds a CouponSchedule (count, previous, next), and
    private helpers: couponDate, couponsAfter, coerceToCouponFrequency, daysBeforeSettlement,
    thirtyDayPeriodLength, thirtyDayMonthDay, actualCouponPeriodLength and driftedCouponDate.
  • Reused from Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799 without changes: DateTimeHelper.dayCountByBasis (COUPDAYBS for every basis, COUPDAYSNC for
    bases 1–4), strictNumber, coerceToSecurityDate and coerceToDayCountBasis. Also reuses offsetMonth and
    daysInMonth from DateTimeHelper; nothing is added there.
  • Constant time: couponsAfter counts the coupon dates without stepping through the periods (a quarterly schedule
    over the whole supported date range has 32,400 of them), and driftedCouponDate stops after 2 × frequency steps,
    after which the drifted day no longer changes.
  • New error message ErrorMessage.CouponFrequency ("Frequency must be 1, 2 or 4."). Empty arguments use the existing
    EmptyArg. No new error types.
  • Catalogue entries in categories/financial.ts, and names in all language packs, read from Microsoft Excel Online
    in each language. csCZ and idID keep the English names, as Excel does.
  • The six functions are added to UNGROUPED_FUNCTIONS in functionCapabilities.ts, like Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799's.
  • Changelog entry under Added.

How did you test your changes?

  • Expected values come from Microsoft Excel Online (en-US). Each test is one measured case: the formula in the
    test is the formula measured in Excel, on the same fixture layout, so any case can be re-checked by entering it in
    Excel. Cases cover the docs examples, every basis, frequency values and truncation, settlement against maturity,
    settlement on and next to a coupon date, end-of-month and February maturities, date bounds, coercion of every
    argument, error precedence, empty arguments, arrays and nesting. Config-dependent cases pass their config:
    dateFormats: ['MM/DD/YYYY', 'YYYY-MM-DD'] for text dates, useArrayArithmetic: true for array constants. Dates
    before 1 March 1900 pin HyperFormula's results.
  • 2,910 new tests in hyperformula-tests: function-couppcd 394, function-coupncd 383, function-coupnum 399,
    function-coupdaybs 587, function-coupdaysnc 634, function-coupdays 513.
  • Day-count schedules. Besides the main cases, Excel was measured on 1,605 basis-1 schedules (maturity on the
    15th and on the 28th to the 31st of every month, frequencies 1, 2 and 4, settlement the day after each of the five
    previous coupon dates) and 354 basis-0 schedules around month ends. The specs take the rows that exercise a rule rather than all of them: for COUPDAYS,
    the 120 schedules where the period length is not COUPNCD − COUPPCD plus the 5 others where the end-of-month step
    back passes settlement (125); for COUPDAYBS (207) and COUPDAYSNC (254), every schedule where a 30/360 month-end
    rule changes that function's result. The implementation matches Excel on all 1,959 schedules (6,585 values, checked outside the specs).
  • All new tests failed before the engine change (the functions were unknown, #NAME?).
  • Full Jest suite with the private tests: 532 suites, 10,334 passed, 3 skipped (pre-existing). Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799's six specs
    and the YEARFRAC spec pass unchanged.
  • Mutation check: breaking one rule per function fails tests: COUPPCD without the end-of-month rule: 44;
    COUPNCD with settlement on a coupon date counted as before it: 46; COUPNUM without the day check: 103;
    COUPDAYBS in actual days for every basis: 185; COUPDAYSNC without the February end counted as 30: 202;
    COUPDAYS basis 1 without the second end-of-month step: 21, and without the stop at settlement: 9.
  • npm run lint (0 errors), npm run compile, npm run docs:generate-function-docs, git diff --check.

Types of changes

  • Breaking change (a fix or a feature because of which an existing functionality doesn't work as expected anymore)
  • New feature or improvement (a non-breaking change that adds functionality)
  • Bug fix (a non-breaking change that fixes an issue)
  • Additional language file, or a change to an existing language file (translations)
  • Change to the documentation

Related issues:

  1. Depends on Add ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED and YIELDDISC functions #1799.
  2. Tests: handsontable/hyperformula-tests#77

Checklist:

  • I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project.
  • I have signed the Contributor License Agreement.
  • My change is compliant with the OpenDocument standard.
  • My change is compatible with Microsoft Excel.
  • My change is compatible with Google Sheets.
  • I described my changes in the CHANGELOG.md file.
  • My changes require a documentation update.
  • My changes require a migration guide.

🤖 Generated with Claude Code


Note

Medium Risk
Large additive surface in financial/date logic where Excel parity and edge cases (30/360, month-end coupons) can affect numeric results; no changes to core mutation or auth paths.

Overview
Adds twelve Excel-style bond/financial functions and the shared date math they depend on.

Coupon schedule (this PR’s focus): COUPPCD, COUPNCD, COUPNUM, COUPDAYBS, COUPDAYSNC, and COUPDAYS are implemented in FinancialPlugin via a common couponFunction runner that builds a CouponSchedule (previous/next coupon dates, count) from settlement, maturity, and frequency (1/2/4), with Excel-specific rules for month-end maturities and basis-0/1 day counts.

Discounted securities (stacked with #1799 in this diff): ACCRINTM, DISC, INTRATE, PRICEDISC, RECEIVED, and YIELDDISC share discountedSecurity validation and formulas using the same day-count basis.

Shared infrastructure: DateTimeHelper.dayCountByBasis (and daysIn30DayMonths) centralize US/European 30/360 and actual-day bases; YEARFRAC is refactored to call it. New ErrorMessage.CouponFrequency, function metadata, i18n entries, license UNGROUPED_FUNCTIONS entries, changelog, and an ACCRINTM row in the differences doc accompany the engine changes.

Reviewed by Cursor Bugbot for commit c86d1e4. Bugbot is set up for automated code reviews on this repo. Configure here.

Tobiadefami and others added 5 commits October 7, 2026 22:12
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 <noreply@anthropic.com>
…nctions

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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the CHANGELOG conflict and lists ACCRINTM, DISC, INTRATE, PRICEDISC,
RECEIVED and YIELDDISC in the ungrouped function capabilities, as the license
capability completeness invariant requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cla-external-contractor-signup

Copy link
Copy Markdown

@Tobiadefami thanks for the pull request. No CLA step needed here — our records show you signed the Contributor License Agreement on 2026-07-31. That signature came from our previous signing form and has been carried over, so there is nothing for you to re-sign.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/interpreter/plugin/FinancialPlugin.ts
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
hyperformula-docs c86d1e4 Commit Preview URL

Branch Preview URL
Oct 09 2026, 08:49 PM

@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Performance comparison of head (c86d1e4) vs base (23ab1a4)

                                     testName |    base |    head | change
--------------------------------------------------------------------------
                                      Sheet A |  492.88 |  502.42 | +1.94%
                                      Sheet B |  158.57 |  163.97 | +3.41%
                                      Sheet T |  142.71 |  144.47 | +1.23%
                                Column ranges |  477.27 |  482.66 | +1.13%
                                Sorted lookup | 14582.1 | 14233.1 | -2.39%
Sheet A:  change value, add/remove row/column |   18.26 |   16.44 | -9.97%
 Sheet B: change value, add/remove row/column |  146.68 |  142.16 | -3.08%
                   Column ranges - add column |  160.71 |  157.32 | -2.11%
                Column ranges - without batch |  491.96 |  485.86 | -1.24%
                        Column ranges - batch |  123.51 |  117.84 | -4.59%

Tobiadefami and others added 4 commits October 9, 2026 21:36
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c86d1e4. Configure here.

return new CellError(ErrorType.NUM, ErrorMessage.ValueSmall)
}
const {dayCount, yearDays} = this.dateTimeHelper.dayCountByBasis(settlement, maturity, basis)
return calculate(dayCount, yearDays, firstAmount, secondAmount)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Zero day-count yields Infinity or NaN

Medium Severity

DISC, INTRATE and YIELDDISC divide by dayCount after settlement is only required to be strictly before maturity. Under basis 0 or 4, 30/360 can still count zero days (for example the 30th and 31st of the same month), so the formula returns Infinity or NaN instead of a cell error.

Additional Locations (3)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c86d1e4. Configure here.

@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.54545% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 97.32%. Comparing base (eff7928) to head (c86d1e4).
⚠️ Report is 6 commits behind head on develop.

Files with missing lines Patch % Lines
src/DateTimeHelper.ts 94.44% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop    #1804      +/-   ##
===========================================
+ Coverage    97.28%   97.32%   +0.04%     
===========================================
  Files          207      207              
  Lines        16218    16426     +208     
  Branches      3588     3634      +46     
===========================================
+ Hits         15777    15986     +209     
+ Misses         433      432       -1     
  Partials         8        8              
Files with missing lines Coverage Δ
src/error-message.ts 100.00% <100.00%> (ø)
src/i18n/languages/csCZ.ts 100.00% <ø> (ø)
src/i18n/languages/daDK.ts 100.00% <ø> (ø)
src/i18n/languages/deDE.ts 100.00% <ø> (ø)
src/i18n/languages/enGB.ts 100.00% <ø> (ø)
src/i18n/languages/esES.ts 100.00% <ø> (ø)
src/i18n/languages/fiFI.ts 100.00% <ø> (ø)
src/i18n/languages/frFR.ts 100.00% <ø> (ø)
src/i18n/languages/huHU.ts 100.00% <ø> (ø)
src/i18n/languages/idID.ts 100.00% <ø> (ø)
... and 13 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant