@travishorn/financejs 1.17.1 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,9 +111,9 @@ Tiers 1-3 are complete.
111
111
  - **Tier 2:** ✓ipmt, ✓ppmt, ✓cumipmt, ✓cumprinc, ✓sln, ✓db, ✓ddb, ✓effect,
112
112
  ✓nominal, ✓syd, ✓mirr
113
113
  - **Tier 3:** ✓rri, ✓pduration, ✓vdb, ✓fvschedule, ✓dollarde, ✓dollarfr, ✓ispmt
114
- - **Tier 4:** ✓yield, price, duration, mduration, disc, intrate, received,
114
+ - **Tier 4:** ✓yield, price, duration, mduration, disc, intrate, received,
115
115
  pricedisc, pricemat, yielddisc, yieldmat
116
- - **Tier 5:** all others
116
+ - **Tier 5:** ✓coupdaybs, ✓coupdays, ✓coupdaysnc, all others
117
117
 
118
118
  ## License
119
119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travishorn/financejs",
3
- "version": "1.17.1",
3
+ "version": "1.19.0",
4
4
  "description": "Modern JavaScript time value of money and cash-flow financial formulas with Excel-style behavior.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,64 @@
1
+ import {
2
+ coupdaybs as coupdaybsUtil,
3
+ getCouponBounds,
4
+ toUtcDate,
5
+ } from "./util.js";
6
+
7
+ /**
8
+ * Returns the number of days from the beginning of the coupon period to the
9
+ * settlement date.
10
+ *
11
+ * Remarks:
12
+ * - `settlement`, `maturity`, `frequency`, and `basis` are truncated to
13
+ * integers.
14
+ * - If `settlement` or `maturity` is not a valid date, an error is thrown.
15
+ * - If `frequency` is any number other than `1`, `2`, or `4`, an error is
16
+ * thrown.
17
+ * - If `basis` < `0` or if `basis` > `4`, an error is thrown.
18
+ * - If `settlement` >= `maturity`, an error is thrown.
19
+ *
20
+ * @param {Date} settlement - The security's settlement date.
21
+ * @param {Date} maturity - The security's maturity date.
22
+ * @param {1|2|4} frequency - The number of coupon payments per year. For annual
23
+ * payments, frequency = `1`; for semiannual, frequency = `2`; for quarterly,
24
+ * frequency = `4`.
25
+ * @param {0|1|2|3|4} [basis=0] - The type of day count basis to use. `0` or
26
+ * omitted = US (NASD 30/360), `1` = actual/actual, `2` = actual/360, `3` =
27
+ * actual/365, `4` = European 30/360.
28
+ * @returns {number} The number of days from the beginning of the coupon period
29
+ * to the settlement date.
30
+ *
31
+ * @example
32
+ * coupdaybs(new Date("2011-01-25"), new Date("2011-11-15"), 2, 1); // 71
33
+ */
34
+ export function coupdaybs(settlement, maturity, frequency, basis = 0) {
35
+ const settlementDate = toUtcDate(settlement);
36
+ const maturityDate = toUtcDate(maturity);
37
+
38
+ frequency = /** @type {1|2|4} */ (Math.trunc(frequency));
39
+ const basisNumber = Math.trunc(basis ?? 0);
40
+
41
+ if (![1, 2, 4].includes(frequency)) {
42
+ throw new RangeError("Invalid frequency.");
43
+ }
44
+
45
+ if (basisNumber < 0 || basisNumber > 4) {
46
+ throw new RangeError("Invalid basis.");
47
+ }
48
+
49
+ /** @type {0|1|2|3|4} */
50
+ const normalizedBasis = /** @type {0|1|2|3|4} */ (basisNumber);
51
+
52
+ if (settlementDate >= maturityDate) {
53
+ throw new RangeError("Settlement must be before maturity.");
54
+ }
55
+
56
+ const monthsPerCoupon = 12 / frequency;
57
+ const { previousCouponDate } = getCouponBounds(
58
+ settlementDate,
59
+ maturityDate,
60
+ monthsPerCoupon,
61
+ );
62
+
63
+ return coupdaybsUtil(previousCouponDate, settlementDate, normalizedBasis);
64
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ coupdays as coupdaysUtil,
3
+ getCouponBounds,
4
+ toUtcDate,
5
+ } from "./util.js";
6
+
7
+ /**
8
+ * Returns the number of days in the coupon period that contains the settlement
9
+ * date.
10
+ *
11
+ * Remarks:
12
+ * - `settlement`, `maturity`, `frequency`, and `basis` are truncated to
13
+ * integers.
14
+ * - If `settlement` or `maturity` is not a valid date, an error is thrown.
15
+ * - If `frequency` is any number other than `1`, `2`, or `4`, an error is
16
+ * thrown.
17
+ * - If `basis` < `0` or if `basis` > `4`, an error is thrown.
18
+ * - If `settlement` >= `maturity`, an error is thrown.
19
+ *
20
+ * @param {Date} settlement - The security's settlement date.
21
+ * @param {Date} maturity - The security's maturity date.
22
+ * @param {1|2|4} frequency - The number of coupon payments per year. For annual
23
+ * payments, frequency = `1`; for semiannual, frequency = `2`; for quarterly,
24
+ * frequency = `4`.
25
+ * @param {0|1|2|3|4} [basis=0] - The type of day count basis to use. `0` or
26
+ * omitted = US (NASD 30/360), `1` = actual/actual, `2` = actual/360, `3` =
27
+ * actual/365, `4` = European 30/360.
28
+ * @returns {number} The number of days in the coupon period that contains the
29
+ * settlement date.
30
+ *
31
+ * @example
32
+ * coupdays(new Date("2011-01-25"), new Date("2011-11-15"), 2, 1); // 184
33
+ */
34
+ export function coupdays(settlement, maturity, frequency, basis = 0) {
35
+ const settlementDate = toUtcDate(settlement);
36
+ const maturityDate = toUtcDate(maturity);
37
+
38
+ frequency = /** @type {1|2|4} */ (Math.trunc(frequency));
39
+ const basisNumber = Math.trunc(basis ?? 0);
40
+
41
+ if (![1, 2, 4].includes(frequency)) {
42
+ throw new RangeError("Invalid frequency.");
43
+ }
44
+
45
+ if (basisNumber < 0 || basisNumber > 4) {
46
+ throw new RangeError("Invalid basis.");
47
+ }
48
+
49
+ /** @type {0|1|2|3|4} */
50
+ const normalizedBasis = /** @type {0|1|2|3|4} */ (basisNumber);
51
+
52
+ if (settlementDate >= maturityDate) {
53
+ throw new RangeError("Settlement must be before maturity.");
54
+ }
55
+
56
+ const monthsPerCoupon = 12 / frequency;
57
+ const { previousCouponDate, nextCouponDate } = getCouponBounds(
58
+ settlementDate,
59
+ maturityDate,
60
+ monthsPerCoupon,
61
+ );
62
+
63
+ return coupdaysUtil(
64
+ previousCouponDate,
65
+ nextCouponDate,
66
+ frequency,
67
+ normalizedBasis,
68
+ );
69
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ coupdaysnc as coupdaysncUtil,
3
+ getCouponBounds,
4
+ toUtcDate,
5
+ } from "./util.js";
6
+
7
+ /**
8
+ * Returns the number of days from the settlement date to the next coupon date.
9
+ *
10
+ * Remarks:
11
+ * - `settlement`, `maturity`, `frequency`, and `basis` are truncated to
12
+ * integers.
13
+ * - If `settlement` or `maturity` is not a valid date, an error is thrown.
14
+ * - If `frequency` is any number other than `1`, `2`, or `4`, an error is
15
+ * thrown.
16
+ * - If `basis` < `0` or if `basis` > `4`, an error is thrown.
17
+ * - If `settlement` >= `maturity`, an error is thrown.
18
+ *
19
+ * @param {Date} settlement - The security's settlement date.
20
+ * @param {Date} maturity - The security's maturity date.
21
+ * @param {1|2|4} frequency - The number of coupon payments per year. For annual
22
+ * payments, frequency = `1`; for semiannual, frequency = `2`; for quarterly,
23
+ * frequency = `4`.
24
+ * @param {0|1|2|3|4} [basis=0] - The type of day count basis to use. `0` or
25
+ * omitted = US (NASD 30/360), `1` = actual/actual, `2` = actual/360, `3` =
26
+ * actual/365, `4` = European 30/360.
27
+ * @returns {number} The number of days from the settlement date to the next
28
+ * coupon date.
29
+ *
30
+ * @example
31
+ * coupdaysnc(new Date("2011-01-25"), new Date("2011-11-15"), 2, 1); // 113
32
+ */
33
+ export function coupdaysnc(settlement, maturity, frequency, basis = 0) {
34
+ const settlementDate = toUtcDate(settlement);
35
+ const maturityDate = toUtcDate(maturity);
36
+
37
+ frequency = /** @type {1|2|4} */ (Math.trunc(frequency));
38
+ const basisNumber = Math.trunc(basis ?? 0);
39
+
40
+ if (![1, 2, 4].includes(frequency)) {
41
+ throw new RangeError("Invalid frequency.");
42
+ }
43
+
44
+ if (basisNumber < 0 || basisNumber > 4) {
45
+ throw new RangeError("Invalid basis.");
46
+ }
47
+
48
+ /** @type {0|1|2|3|4} */
49
+ const normalizedBasis = /** @type {0|1|2|3|4} */ (basisNumber);
50
+
51
+ if (settlementDate >= maturityDate) {
52
+ throw new RangeError("Settlement must be before maturity.");
53
+ }
54
+
55
+ const monthsPerCoupon = 12 / frequency;
56
+ const { nextCouponDate } = getCouponBounds(
57
+ settlementDate,
58
+ maturityDate,
59
+ monthsPerCoupon,
60
+ );
61
+
62
+ return coupdaysncUtil(settlementDate, nextCouponDate, normalizedBasis);
63
+ }
package/src/fv.js CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeZero } from "./normalizeZero.js";
1
+ import { normalizeZero } from "./util.js";
2
2
 
3
3
  /**
4
4
  * Calculates the future value of an investment based on a constant interest
package/src/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ export { coupdaybs } from "./coupdaybs.js";
2
+ export { coupdays } from "./coupdays.js";
3
+ export { coupdaysnc } from "./coupdaysnc.js";
1
4
  export { cumipmt } from "./cumipmt.js";
2
5
  export { cumprinc } from "./cumprinc.js";
3
6
  export { db } from "./db.js";
@@ -15,6 +18,7 @@ export { nominal } from "./nominal.js";
15
18
  export { nper } from "./nper.js";
16
19
  export { npv } from "./npv.js";
17
20
  export { pduration } from "./pduration.js";
21
+ export { price } from "./price.js";
18
22
  export { pmt } from "./pmt.js";
19
23
  export { ppmt } from "./ppmt.js";
20
24
  export { pv } from "./pv.js";
package/src/nper.js CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeZero } from "./normalizeZero.js";
1
+ import { normalizeZero } from "./util.js";
2
2
 
3
3
  /**
4
4
  * Calculates the number of periods for an investment based on periodic,
package/src/pmt.js CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeZero } from "./normalizeZero.js";
1
+ import { normalizeZero } from "./util.js";
2
2
 
3
3
  /**
4
4
  * Calculates the payment for a loan based on constant payments and a constant
package/src/ppmt.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ipmt } from "./ipmt.js";
2
- import { normalizeZero } from "./normalizeZero.js";
3
2
  import { pmt } from "./pmt.js";
3
+ import { normalizeZero } from "./util.js";
4
4
 
5
5
  /**
6
6
  * Calculates the payment on the principal for a given period for an investment
package/src/price.js ADDED
@@ -0,0 +1,149 @@
1
+ import {
2
+ actualDays,
3
+ coupdaybs,
4
+ coupdays,
5
+ coupdaysnc,
6
+ couponsRemaining,
7
+ getCouponBounds,
8
+ normalizeZero,
9
+ toUtcDate,
10
+ } from "./util.js";
11
+
12
+ /**
13
+ * Calculates the price per $100 face value of a security that pays periodic
14
+ * interest.
15
+ *
16
+ * Remarks:
17
+ * - The settlement date is the date a buyer purchases a coupon, such as a bond.
18
+ * The maturity date is the date when a coupon expires. For example, suppose a
19
+ * 30-year bond is issued on January 1, 2008, and is purchased by a buyer six
20
+ * months later. The issue date would be January 1, 2008, the settlement date
21
+ * would be July 1, 2008, and the maturity date would be January 1, 2038,
22
+ * which is 30 years after the January 1, 2008, issue date.
23
+ * - `settlement`, `maturity`, `frequency`, and `basis` are truncated to
24
+ * integers.
25
+ * - If `settlement` or `maturity` is not a valid date, an error is thrown.
26
+ * - If `yld` < `0` or if `rate` < `0`, an error is thrown.
27
+ * - If `redemption` <= `0`, an error is thrown.
28
+ * - If `frequency` is any number other than `1`, `2`, or `4`, an error is
29
+ * thrown.
30
+ * - If `basis` < `0` or if `basis` > `4` and error is thrown.
31
+ * - If `settlement` >= `maturity`, an error is thrown.
32
+ * - When N > 1 (N is the number of coupons payable between the settlement date
33
+ * and redemption date), the calculation is: `PRICE =[redemption / (1 +
34
+ * yld/frequency)^(N-1 + DSC/E)] +[SUM(k=1 to N) (100 * rate/frequency) / (1 +
35
+ * yld/frequency)^(k-1 + DSC/E)] - (100 * rate/frequency * A/E)`, where:
36
+ * - DSC = number of days from settlement to next coupon date.
37
+ * - E = number of days in coupon period in which the settlement date falls.
38
+ * - A = number of days from beginning of coupon period to settlement date.
39
+ * - When N = 1, the calculation is: `DSR = E - A; T1 = 100 * (rate / frequency)
40
+ * + redemption; T2 = (yld / frequency) * (DSR / E) + 1; T3 = 100 * (rate /
41
+ * frequency) * (A / E); Price = (T1 / T2) - T3`.
42
+ *
43
+ * @param {Date} settlement - The security's settlement date. The security
44
+ * settlement date is the date after the issue date when the security is traded
45
+ * to the buyer.
46
+ * @param {Date} maturity - The security's maturity date. The maturity date is
47
+ * the date when the security expires.
48
+ * @param {number} rate - The security's annual coupon rate.
49
+ * @param {number} yld - The security's annual yield.
50
+ * @param {number} redemption - The security's redemption value per $100 face
51
+ * value.
52
+ * @param {1|2|4} frequency - The number of coupon payments per year. For annual
53
+ * payments, frequency = `1`; for semiannual, frequency = `2`; for quarterly,
54
+ * frequency = `4`.
55
+ * @param {0|1|2|3|4} [basis=0] - The type of day count basis to use. `0` or
56
+ * omitted = US (NASD 30/360), `1` = actual/actual, `2` = actual/360, `3` =
57
+ * actual/365, `4` = European 30/360.
58
+ * @returns {number} the price per $100 face value
59
+ *
60
+ * @example
61
+ * price(new Date("2008-02-15"), new Date("2017-11-15"), 0.0575, 0.065, 100, 2, 0); // 94.63436162
62
+ */
63
+ export function price(
64
+ settlement,
65
+ maturity,
66
+ rate,
67
+ yld,
68
+ redemption,
69
+ frequency,
70
+ basis = 0,
71
+ ) {
72
+ const settlementDate = toUtcDate(settlement);
73
+ const maturityDate = toUtcDate(maturity);
74
+
75
+ frequency = /** @type {1|2|4} */ (Math.trunc(frequency));
76
+ const basisNumber = Math.trunc(basis ?? 0);
77
+
78
+ if (rate < 0 || yld < 0) {
79
+ throw new RangeError(
80
+ "Rate and yield must be greater than or equal to zero.",
81
+ );
82
+ }
83
+
84
+ if (redemption <= 0) {
85
+ throw new RangeError("Redemption must be greater than zero.");
86
+ }
87
+
88
+ if (![1, 2, 4].includes(frequency)) {
89
+ throw new RangeError("Invalid frequency.");
90
+ }
91
+
92
+ if (basisNumber < 0 || basisNumber > 4) {
93
+ throw new RangeError("Invalid basis.");
94
+ }
95
+
96
+ /** @type {0|1|2|3|4} */
97
+ const normalizedBasis = /** @type {0|1|2|3|4} */ (basisNumber);
98
+
99
+ if (settlementDate >= maturityDate) {
100
+ throw new RangeError("Settlement must be before maturity.");
101
+ }
102
+
103
+ const monthsPerCoupon = 12 / frequency;
104
+ const { previousCouponDate, nextCouponDate } = getCouponBounds(
105
+ settlementDate,
106
+ maturityDate,
107
+ monthsPerCoupon,
108
+ );
109
+
110
+ const a = coupdaybs(previousCouponDate, settlementDate, normalizedBasis);
111
+ let dsc = coupdaysnc(settlementDate, nextCouponDate, normalizedBasis);
112
+ let e = coupdays(
113
+ previousCouponDate,
114
+ nextCouponDate,
115
+ frequency,
116
+ normalizedBasis,
117
+ );
118
+
119
+ if (normalizedBasis === 2) {
120
+ e = actualDays(previousCouponDate, nextCouponDate);
121
+ }
122
+
123
+ if (normalizedBasis === 3) {
124
+ dsc = e - a;
125
+ }
126
+
127
+ const n = couponsRemaining(nextCouponDate, maturityDate, monthsPerCoupon);
128
+ const coupon = (100 * rate) / frequency;
129
+
130
+ if (n <= 1) {
131
+ const t1 = coupon + redemption;
132
+ const t2 = (yld / frequency) * (dsc / e) + 1;
133
+ const t3 = coupon * (a / e);
134
+ return normalizeZero(t1 / t2 - t3);
135
+ }
136
+
137
+ const base = 1 + yld / frequency;
138
+ const firstExponent = dsc / e;
139
+
140
+ let presentValue = 0;
141
+ for (let k = 1; k <= n; k += 1) {
142
+ presentValue += coupon / Math.pow(base, k - 1 + firstExponent);
143
+ }
144
+
145
+ presentValue += redemption / Math.pow(base, n - 1 + firstExponent);
146
+ presentValue -= (coupon * a) / e;
147
+
148
+ return normalizeZero(presentValue);
149
+ }
package/src/pv.js CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeZero } from "./normalizeZero.js";
1
+ import { normalizeZero } from "./util.js";
2
2
 
3
3
  /**
4
4
  * Calculates the present value of a loan or an investment, based on a constant
package/src/rate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { normalizeZero } from "./normalizeZero.js";
1
+ import { normalizeZero } from "./util.js";
2
2
 
3
3
  /**
4
4
  * Evaluates the annuity equation for a candidate interest rate.
package/src/util.js ADDED
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Gets the last calendar day number for the month that contains `date`.
3
+ *
4
+ * This helper is UTC-based, so results are not affected by local timezone or
5
+ * daylight-saving transitions.
6
+ *
7
+ * @param {Date} date - The date whose month should be inspected.
8
+ * @returns {number} The month-end day of month, from 28 to 31.
9
+ *
10
+ * @example
11
+ * lastDayOfMonthUtc(new Date("2024-02-10")); // 29
12
+ * lastDayOfMonthUtc(new Date("2023-02-10")); // 28
13
+ */
14
+ export function lastDayOfMonthUtc(date) {
15
+ return new Date(
16
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
17
+ ).getUTCDate();
18
+ }
19
+
20
+ /**
21
+ * Adds or subtracts whole months from a UTC date while preserving end-of-month
22
+ * behavior.
23
+ *
24
+ * If the input date is the last day of its month, the result is also forced to
25
+ * the last day of the destination month. Otherwise, the day is clamped to the
26
+ * destination month when needed (for example, Jan 30 + 1 month => Feb 28/29).
27
+ *
28
+ * @param {Date} date - The starting date.
29
+ * @param {number} months - Number of months to add. Use negative values to
30
+ * subtract months.
31
+ * @returns {Date} A new UTC date shifted by `months`.
32
+ *
33
+ * @example
34
+ * addMonthsUtc(new Date("2021-01-31"), 1); // 2021-02-28T00:00:00.000Z
35
+ * addMonthsUtc(new Date("2024-01-31"), 1); // 2024-02-29T00:00:00.000Z
36
+ */
37
+ export function addMonthsUtc(date, months) {
38
+ const year = date.getUTCFullYear();
39
+ const month = date.getUTCMonth();
40
+ const day = date.getUTCDate();
41
+ const isEndOfMonth = day === lastDayOfMonthUtc(date);
42
+
43
+ const monthIndex = month + months;
44
+ const newYear = year + Math.floor(monthIndex / 12);
45
+ const newMonth = ((monthIndex % 12) + 12) % 12;
46
+ const monthEndDay = new Date(Date.UTC(newYear, newMonth + 1, 0)).getUTCDate();
47
+ const newDay = isEndOfMonth ? monthEndDay : Math.min(day, monthEndDay);
48
+
49
+ return new Date(Date.UTC(newYear, newMonth, newDay));
50
+ }
51
+
52
+ /**
53
+ * Computes day count using Excel/NASD 30/360 convention.
54
+ *
55
+ * This convention applies special normalization around month-end boundaries,
56
+ * especially for February and day 31 handling, to produce a synthetic
57
+ * 360-day-year day count.
58
+ *
59
+ * @param {Date} start - Start date (inclusive boundary for convention logic).
60
+ * @param {Date} end - End date.
61
+ * @returns {number} Day count between `start` and `end` under US 30/360 rules.
62
+ *
63
+ * @example
64
+ * days360Us(new Date("2024-01-30"), new Date("2024-03-31")); // 60
65
+ */
66
+ export function days360Us(start, end) {
67
+ let d1 = start.getUTCDate();
68
+ let d2 = end.getUTCDate();
69
+ const m1 = start.getUTCMonth() + 1;
70
+ const m2 = end.getUTCMonth() + 1;
71
+ const y1 = start.getUTCFullYear();
72
+ const y2 = end.getUTCFullYear();
73
+
74
+ const startIsMonthEnd = d1 === lastDayOfMonthUtc(start);
75
+ const endIsMonthEnd = d2 === lastDayOfMonthUtc(end);
76
+
77
+ if (m1 === 2 && startIsMonthEnd) {
78
+ d1 = 30;
79
+ }
80
+ if (m2 === 2 && endIsMonthEnd && m1 === 2 && startIsMonthEnd) {
81
+ d2 = 30;
82
+ }
83
+
84
+ if (d2 === 31 && d1 >= 30) {
85
+ d2 = 30;
86
+ }
87
+ if (d1 === 31) {
88
+ d1 = 30;
89
+ }
90
+
91
+ return 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
92
+ }
93
+
94
+ /**
95
+ * Computes day count using European 30/360 convention.
96
+ *
97
+ * Unlike NASD 30/360, this method simply converts day 31 to day 30 for both
98
+ * dates, then calculates using a 360-day year basis.
99
+ *
100
+ * @param {Date} start - Start date.
101
+ * @param {Date} end - End date.
102
+ * @returns {number} Day count between `start` and `end` under EU 30/360 rules.
103
+ *
104
+ * @example
105
+ * days360Eu(new Date("2024-01-31"), new Date("2024-03-31")); // 60
106
+ */
107
+ export function days360Eu(start, end) {
108
+ let d1 = start.getUTCDate();
109
+ let d2 = end.getUTCDate();
110
+
111
+ if (d1 === 31) {
112
+ d1 = 30;
113
+ }
114
+ if (d2 === 31) {
115
+ d2 = 30;
116
+ }
117
+
118
+ const m1 = start.getUTCMonth() + 1;
119
+ const m2 = end.getUTCMonth() + 1;
120
+ const y1 = start.getUTCFullYear();
121
+ const y2 = end.getUTCFullYear();
122
+
123
+ return 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
124
+ }
125
+
126
+ /**
127
+ * Computes the actual elapsed days between two dates using UTC timestamps.
128
+ *
129
+ * @param {Date} start - Start date.
130
+ * @param {Date} end - End date.
131
+ * @returns {number} Exact day difference as `(end - start)` in days.
132
+ *
133
+ * @example
134
+ * actualDays(new Date("2024-01-01"), new Date("2024-01-11")); // 10
135
+ */
136
+ export function actualDays(start, end) {
137
+ const msPerDay = 24 * 60 * 60 * 1000;
138
+ return (end.getTime() - start.getTime()) / msPerDay;
139
+ }
140
+
141
+ /**
142
+ * Finds coupon period boundaries around a settlement date.
143
+ *
144
+ * Starting from maturity and stepping backward by coupon interval, this helper
145
+ * returns the coupon date immediately before settlement and the next coupon
146
+ * date after settlement. If settlement lands exactly on a coupon date, it is
147
+ * treated as the start of the next period (Excel-compatible behavior).
148
+ *
149
+ * @param {Date} settlementDate - Security settlement date.
150
+ * @param {Date} maturityDate - Security maturity date.
151
+ * @param {number} monthsPerCoupon - Coupon interval in months (e.g., 12, 6, or
152
+ * 3).
153
+ * @returns {{ previousCouponDate: Date, nextCouponDate: Date }} Coupon bounds
154
+ * containing settlement.
155
+ *
156
+ * @example
157
+ * getCouponBounds(new Date("2021-01-15"), new Date("2022-01-31"), 6);
158
+ */
159
+ export function getCouponBounds(settlementDate, maturityDate, monthsPerCoupon) {
160
+ let nextCouponDate = new Date(maturityDate.getTime());
161
+ let previousCouponDate = addMonthsUtc(nextCouponDate, -monthsPerCoupon);
162
+
163
+ while (settlementDate < previousCouponDate) {
164
+ nextCouponDate = previousCouponDate;
165
+ previousCouponDate = addMonthsUtc(nextCouponDate, -monthsPerCoupon);
166
+ }
167
+
168
+ // Excel treats settlement on a coupon date as the start of the next period.
169
+ if (settlementDate >= nextCouponDate) {
170
+ previousCouponDate = nextCouponDate;
171
+ nextCouponDate = addMonthsUtc(nextCouponDate, monthsPerCoupon);
172
+ }
173
+
174
+ return { previousCouponDate, nextCouponDate };
175
+ }
176
+
177
+ /**
178
+ * Counts remaining coupon payments from `nextCouponDate` through maturity.
179
+ *
180
+ * The count includes the coupon on `nextCouponDate` itself, then steps by the
181
+ * coupon interval until the maturity date is reached.
182
+ *
183
+ * @param {Date} nextCouponDate - Next coupon date after settlement.
184
+ * @param {Date} maturityDate - Security maturity date.
185
+ * @param {number} monthsPerCoupon - Coupon interval in months.
186
+ * @returns {number} Number of remaining coupon payments.
187
+ *
188
+ * @example
189
+ * couponsRemaining(new Date("2025-01-01"), new Date("2026-01-01"), 6); // 3
190
+ */
191
+ export function couponsRemaining(
192
+ nextCouponDate,
193
+ maturityDate,
194
+ monthsPerCoupon,
195
+ ) {
196
+ let n = 1;
197
+ let current = new Date(nextCouponDate.getTime());
198
+
199
+ while (current < maturityDate) {
200
+ current = addMonthsUtc(current, monthsPerCoupon);
201
+ n += 1;
202
+ }
203
+
204
+ return n;
205
+ }
206
+
207
+ /**
208
+ * Computes days from settlement to next coupon date (`DSC`) by day-count basis.
209
+ *
210
+ * Basis mapping:
211
+ * - `0`: US (NASD) 30/360
212
+ * - `1`: Actual/actual
213
+ * - `2`: Actual/360
214
+ * - `3`: Actual/365
215
+ * - `4`: European 30/360
216
+ *
217
+ * @param {Date} settlementDate - Settlement date.
218
+ * @param {Date} nextCouponDate - Next coupon date.
219
+ * @param {0|1|2|3|4} basis - Day-count basis code.
220
+ * @returns {number} Days between settlement and next coupon under `basis`.
221
+ *
222
+ * @example
223
+ * coupdaysnc(new Date("2024-01-15"), new Date("2024-04-15"), 0);
224
+ */
225
+ export function coupdaysnc(settlementDate, nextCouponDate, basis) {
226
+ if (basis === 0) {
227
+ return days360Us(settlementDate, nextCouponDate);
228
+ }
229
+
230
+ if (basis === 4) {
231
+ return days360Eu(settlementDate, nextCouponDate);
232
+ }
233
+
234
+ return actualDays(settlementDate, nextCouponDate);
235
+ }
236
+
237
+ /**
238
+ * Computes days from previous coupon date to settlement (`A`) by day-count
239
+ * basis.
240
+ *
241
+ * Basis mapping:
242
+ * - `0`: US (NASD) 30/360
243
+ * - `1`: Actual/actual
244
+ * - `2`: Actual/360
245
+ * - `3`: Actual/365
246
+ * - `4`: European 30/360
247
+ *
248
+ * @param {Date} previousCouponDate - Coupon date immediately before settlement.
249
+ * @param {Date} settlementDate - Settlement date.
250
+ * @param {0|1|2|3|4} basis - Day-count basis code.
251
+ * @returns {number} Days between previous coupon and settlement under `basis`.
252
+ *
253
+ * @example
254
+ * coupdaybs(new Date("2023-10-15"), new Date("2024-01-15"), 1);
255
+ */
256
+ export function coupdaybs(previousCouponDate, settlementDate, basis) {
257
+ if (basis === 0) {
258
+ return days360Us(previousCouponDate, settlementDate);
259
+ }
260
+
261
+ if (basis === 4) {
262
+ return days360Eu(previousCouponDate, settlementDate);
263
+ }
264
+
265
+ return actualDays(previousCouponDate, settlementDate);
266
+ }
267
+
268
+ /**
269
+ * Computes total days in the coupon period (`E`) by basis and payment
270
+ * frequency.
271
+ *
272
+ * For 30/360 bases (`0` and `4`), period length is fixed at `360/frequency`.
273
+ * For basis `3` (Actual/365), period length is fixed at `365/frequency`.
274
+ * Otherwise, calendar days between coupon boundaries are used.
275
+ *
276
+ * @param {Date} previousCouponDate - Coupon date before settlement.
277
+ * @param {Date} nextCouponDate - Coupon date after settlement.
278
+ * @param {number} frequency - Coupon payments per year.
279
+ * @param {0|1|2|3|4} basis - Day-count basis code.
280
+ * @returns {number} Coupon period length in days.
281
+ *
282
+ * @example
283
+ * coupdays(new Date("2024-01-01"), new Date("2024-07-01"), 2, 3); // 182.5
284
+ */
285
+ export function coupdays(previousCouponDate, nextCouponDate, frequency, basis) {
286
+ if (basis === 0 || basis === 2 || basis === 4) {
287
+ return 360 / frequency;
288
+ }
289
+
290
+ if (basis === 3) {
291
+ return 365 / frequency;
292
+ }
293
+
294
+ return actualDays(previousCouponDate, nextCouponDate);
295
+ }
296
+
297
+ /**
298
+ * Converts a `Date` to a UTC-midnight date-only representation.
299
+ *
300
+ * This helper is used to remove time-of-day components so financial date
301
+ * calculations operate on whole UTC dates.
302
+ *
303
+ * @param {Date} value - Input date value.
304
+ * @returns {Date} A new date at `00:00:00.000Z` for the same UTC
305
+ * year/month/day.
306
+ * @throws {RangeError} If `value` is not a valid `Date` instance.
307
+ *
308
+ * @example
309
+ * toUtcDate(new Date("2024-04-03T15:45:12.250Z")); // 2024-04-03T00:00:00.000Z
310
+ */
311
+ export function toUtcDate(value) {
312
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
313
+ throw new RangeError("Settlement and maturity must be valid Date objects.");
314
+ }
315
+
316
+ return new Date(
317
+ Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()),
318
+ );
319
+ }
320
+
321
+ /**
322
+ * Normalizes zero values to ensure consistent handling of positive and negative
323
+ * zero in calculations.
324
+ *
325
+ * In JavaScript, +0 and -0 are distinct values that can yield different results
326
+ * in equality checks (e.g., Object.is(+0, -0) is false), sign checks
327
+ * (Math.sign), and division (1/0 vs 1/-0). This function coerces any zero input
328
+ * (either +0 or -0) to positive zero (+0), providing a consistent
329
+ * representation for downstream calculations, comparisons, and serialization.
330
+ *
331
+ * @param {number} value - The numeric value to normalize. If the value is +0 or
332
+ * -0, returns +0; otherwise, returns the original value.
333
+ * @returns {number} The normalized value, with all zeroes represented as +0.
334
+ *
335
+ * @example
336
+ * normalizeZero(-0); // 0
337
+ * normalizeZero(+0); // 0
338
+ * normalizeZero(5); // 5
339
+ */
340
+ export function normalizeZero(value) {
341
+ return value === 0 ? 0 : value;
342
+ }
package/src/yield_.js CHANGED
@@ -1,3 +1,14 @@
1
+ import {
2
+ actualDays,
3
+ coupdaybs,
4
+ coupdays,
5
+ coupdaysnc,
6
+ couponsRemaining,
7
+ getCouponBounds,
8
+ normalizeZero,
9
+ toUtcDate,
10
+ } from "./util.js";
11
+
1
12
  /**
2
13
  * Calculates the yield on a security that pays periodic interest. Use to
3
14
  * calculate bond yield.
@@ -98,13 +109,17 @@ export function yield_(
98
109
 
99
110
  const a = coupdaybs(previousCouponDate, settlementDate, normalizedBasis);
100
111
  let dsc = coupdaysnc(settlementDate, nextCouponDate, normalizedBasis);
101
- const e = coupdays(
112
+ let e = coupdays(
102
113
  previousCouponDate,
103
114
  nextCouponDate,
104
115
  frequency,
105
116
  normalizedBasis,
106
117
  );
107
118
 
119
+ if (normalizedBasis === 2) {
120
+ e = actualDays(previousCouponDate, nextCouponDate);
121
+ }
122
+
108
123
  if (normalizedBasis === 3) {
109
124
  dsc = e - a;
110
125
  }
@@ -118,7 +133,7 @@ export function yield_(
118
133
  (pr / 100 + (a / e) * (rate / frequency));
119
134
  const denominator = pr / 100 + (a / e) * (rate / frequency);
120
135
  const result = (numerator / denominator) * ((frequency * e) / dsc);
121
- return normalizeNegativeZero(result);
136
+ return normalizeZero(result);
122
137
  }
123
138
 
124
139
  /** @param {number} candidateYield */
@@ -150,17 +165,12 @@ export function yield_(
150
165
 
151
166
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
152
167
  if (Math.abs(f1) < epsilon) {
153
- return normalizeNegativeZero(y1);
168
+ return normalizeZero(y1);
154
169
  }
155
170
 
156
171
  if (f1 === f0) {
157
172
  y0 = Math.max(y0 + step, minYield);
158
173
  f0 = priceFromYield(y0) - pr;
159
- if (f1 === f0) {
160
- throw new RangeError(
161
- "Cannot calculate YIELD with the provided values.",
162
- );
163
- }
164
174
  }
165
175
 
166
176
  let y2 = y1 - ((y1 - y0) * f1) / (f1 - f0);
@@ -168,7 +178,7 @@ export function yield_(
168
178
  const f2 = priceFromYield(y2) - pr;
169
179
 
170
180
  if (Math.abs(f2) < epsilon) {
171
- return normalizeNegativeZero(y2);
181
+ return normalizeZero(y2);
172
182
  }
173
183
 
174
184
  y0 = y1;
@@ -179,207 +189,3 @@ export function yield_(
179
189
 
180
190
  throw new RangeError("Maximum iterations exceeded while calculating YIELD.");
181
191
  }
182
-
183
- /** @param {Date} date */
184
- function lastDayOfMonthUtc(date) {
185
- return new Date(
186
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
187
- ).getUTCDate();
188
- }
189
-
190
- /**
191
- * Month arithmetic that preserves end-of-month behavior for coupon schedules.
192
- *
193
- * @param {Date} date
194
- * @param {number} months
195
- */
196
- function addMonthsUtc(date, months) {
197
- const year = date.getUTCFullYear();
198
- const month = date.getUTCMonth();
199
- const day = date.getUTCDate();
200
- const isEndOfMonth = day === lastDayOfMonthUtc(date);
201
-
202
- const monthIndex = month + months;
203
- const newYear = year + Math.floor(monthIndex / 12);
204
- const newMonth = ((monthIndex % 12) + 12) % 12;
205
- const monthEndDay = new Date(Date.UTC(newYear, newMonth + 1, 0)).getUTCDate();
206
- const newDay = isEndOfMonth ? monthEndDay : Math.min(day, monthEndDay);
207
-
208
- return new Date(Date.UTC(newYear, newMonth, newDay));
209
- }
210
-
211
- /**
212
- * @param {Date} settlementDate
213
- * @param {Date} maturityDate
214
- * @param {number} monthsPerCoupon
215
- */
216
- function getCouponBounds(settlementDate, maturityDate, monthsPerCoupon) {
217
- let nextCouponDate = new Date(maturityDate.getTime());
218
- let previousCouponDate = addMonthsUtc(nextCouponDate, -monthsPerCoupon);
219
-
220
- while (settlementDate < previousCouponDate) {
221
- nextCouponDate = previousCouponDate;
222
- previousCouponDate = addMonthsUtc(nextCouponDate, -monthsPerCoupon);
223
- }
224
-
225
- // Excel treats settlement on a coupon date as the start of the next period.
226
- if (settlementDate >= nextCouponDate) {
227
- previousCouponDate = nextCouponDate;
228
- nextCouponDate = addMonthsUtc(nextCouponDate, monthsPerCoupon);
229
- }
230
-
231
- return { previousCouponDate, nextCouponDate };
232
- }
233
-
234
- /**
235
- * @param {Date} nextCouponDate
236
- * @param {Date} maturityDate
237
- * @param {number} monthsPerCoupon
238
- */
239
- function couponsRemaining(nextCouponDate, maturityDate, monthsPerCoupon) {
240
- let n = 1;
241
- let current = new Date(nextCouponDate.getTime());
242
-
243
- while (current < maturityDate) {
244
- current = addMonthsUtc(current, monthsPerCoupon);
245
- n += 1;
246
- }
247
-
248
- return n;
249
- }
250
-
251
- /**
252
- * @param {Date} settlementDate
253
- * @param {Date} nextCouponDate
254
- * @param {0|1|2|3|4} basis
255
- */
256
- function coupdaysnc(settlementDate, nextCouponDate, basis) {
257
- if (basis === 0) {
258
- return days360Us(settlementDate, nextCouponDate);
259
- }
260
-
261
- if (basis === 4) {
262
- return days360Eu(settlementDate, nextCouponDate);
263
- }
264
-
265
- return actualDays(settlementDate, nextCouponDate);
266
- }
267
-
268
- /**
269
- * @param {Date} previousCouponDate
270
- * @param {Date} settlementDate
271
- * @param {0|1|2|3|4} basis
272
- */
273
- function coupdaybs(previousCouponDate, settlementDate, basis) {
274
- if (basis === 0) {
275
- return days360Us(previousCouponDate, settlementDate);
276
- }
277
-
278
- if (basis === 4) {
279
- return days360Eu(previousCouponDate, settlementDate);
280
- }
281
-
282
- return actualDays(previousCouponDate, settlementDate);
283
- }
284
-
285
- /**
286
- * @param {Date} previousCouponDate
287
- * @param {Date} nextCouponDate
288
- * @param {number} frequency
289
- * @param {0|1|2|3|4} basis
290
- */
291
- function coupdays(previousCouponDate, nextCouponDate, frequency, basis) {
292
- if (basis === 0 || basis === 4) {
293
- return 360 / frequency;
294
- }
295
-
296
- if (basis === 3) {
297
- return 365 / frequency;
298
- }
299
-
300
- return actualDays(previousCouponDate, nextCouponDate);
301
- }
302
-
303
- /**
304
- * Excel/NASD 30/360 day count.
305
- *
306
- * @param {Date} start
307
- * @param {Date} end
308
- */
309
- function days360Us(start, end) {
310
- let d1 = start.getUTCDate();
311
- let d2 = end.getUTCDate();
312
- const m1 = start.getUTCMonth() + 1;
313
- const m2 = end.getUTCMonth() + 1;
314
- const y1 = start.getUTCFullYear();
315
- const y2 = end.getUTCFullYear();
316
-
317
- const startIsMonthEnd = d1 === lastDayOfMonthUtc(start);
318
- const endIsMonthEnd = d2 === lastDayOfMonthUtc(end);
319
-
320
- if (m1 === 2 && startIsMonthEnd) {
321
- d1 = 30;
322
- }
323
- if (m2 === 2 && endIsMonthEnd && m1 === 2 && startIsMonthEnd) {
324
- d2 = 30;
325
- }
326
-
327
- if (d2 === 31 && d1 >= 30) {
328
- d2 = 30;
329
- }
330
- if (d1 === 31) {
331
- d1 = 30;
332
- }
333
-
334
- return 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
335
- }
336
-
337
- /**
338
- * European 30/360 day count.
339
- *
340
- * @param {Date} start
341
- * @param {Date} end
342
- */
343
- function days360Eu(start, end) {
344
- let d1 = start.getUTCDate();
345
- let d2 = end.getUTCDate();
346
-
347
- if (d1 === 31) {
348
- d1 = 30;
349
- }
350
- if (d2 === 31) {
351
- d2 = 30;
352
- }
353
-
354
- const m1 = start.getUTCMonth() + 1;
355
- const m2 = end.getUTCMonth() + 1;
356
- const y1 = start.getUTCFullYear();
357
- const y2 = end.getUTCFullYear();
358
-
359
- return 360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1);
360
- }
361
-
362
- /**
363
- * @param {Date} start
364
- * @param {Date} end
365
- */
366
- function actualDays(start, end) {
367
- const msPerDay = 24 * 60 * 60 * 1000;
368
- return (end.getTime() - start.getTime()) / msPerDay;
369
- }
370
-
371
- /** @param {Date} value */
372
- function toUtcDate(value) {
373
- if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
374
- throw new RangeError("Settlement and maturity must be valid Date objects.");
375
- }
376
-
377
- return new Date(
378
- Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()),
379
- );
380
- }
381
-
382
- /** @param {number} value */
383
- function normalizeNegativeZero(value) {
384
- return Object.is(value, -0) ? 0 : value;
385
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * Normalizes zero values to ensure consistent handling of positive and negative
3
- * zero in calculations.
4
- *
5
- * In JavaScript, +0 and -0 are distinct values that can yield different results
6
- * in equality checks (e.g., Object.is(+0, -0) is false), sign checks
7
- * (Math.sign), and division (1/0 vs 1/-0). This function coerces any zero input
8
- * (either +0 or -0) to positive zero (+0), providing a consistent
9
- * representation for downstream calculations, comparisons, and serialization.
10
- *
11
- * @param {number} value - The numeric value to normalize. If the value is +0 or
12
- * -0, returns +0; otherwise, returns the original value.
13
- * @returns {number} The normalized value, with all zeroes represented as +0.
14
- *
15
- * @example
16
- * normalizeZero(-0); // 0
17
- * normalizeZero(+0); // 0
18
- * normalizeZero(5); // 5
19
- */
20
- export function normalizeZero(value) {
21
- return value === 0 ? 0 : value;
22
- }