@pyverret/ratejs 1.1.0 → 1.1.2

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
@@ -8,6 +8,24 @@ Lightweight, dependency-free TypeScript financial math library providing pure ca
8
8
  npm i @pyverret/ratejs
9
9
  ```
10
10
 
11
+ ## Validate Before Push
12
+
13
+ ```bash
14
+ npm run validate
15
+ ```
16
+
17
+ ## Automated Releases
18
+
19
+ Releases are managed by `semantic-release` on pushes to `main`.
20
+
21
+ Required GitHub secrets:
22
+ - `NPM_TOKEN`: npm automation token with publish access.
23
+
24
+ Behavior:
25
+ - Conventional Commits drive version bumps (`fix` = patch, `feat` = minor, `feat!` or `BREAKING CHANGE` = major).
26
+ - Release workflow runs `npm run validate` before publishing.
27
+ - Git tags use format `vX.Y.Z`.
28
+
11
29
  ## Live Demo
12
30
 
13
31
  - GitHub Pages URL: `https://pyverret.github.io/ratejs/`
@@ -44,16 +62,19 @@ const payment = pmt({
44
62
  ratePerPeriod: 0.06 / 12,
45
63
  periods: 360,
46
64
  presentValue: 250000,
47
- futureValue: 0,
48
- timing: "end",
65
+ futureValue: 0, // optional
66
+ timing: "end", // optional
49
67
  });
50
68
 
51
69
  const impliedRate = rate({
52
70
  periods: 360,
53
71
  payment,
54
72
  presentValue: 250000,
55
- futureValue: 0,
56
- timing: "end",
73
+ futureValue: 0, // optional
74
+ timing: "end", // optional
75
+ guess: 0.1, // optional
76
+ maxIterations: 100, // optional
77
+ tolerance: 1e-10, // optional
57
78
  lowerBound: -0.99, // optional
58
79
  upperBound: 10, // optional
59
80
  });
@@ -62,6 +83,7 @@ const impliedRate = rate({
62
83
  Edge cases:
63
84
  - `nper` throws `RangeError` when `ratePerPeriod <= -1`.
64
85
  - `rate` throws `RangeError` when no root is found within search bounds.
86
+ - `pmt`, `pv`, and `fv` throw `RangeError` when `ratePerPeriod <= -1`.
65
87
 
66
88
  ### Interest & growth
67
89
 
@@ -88,11 +110,11 @@ presentValue({ futureValue: 5000, rate: 0.06, timesPerYear: 12, years: 5 });
88
110
  ```ts
89
111
  investmentGrowth({
90
112
  initial: 1000,
91
- contributionPerPeriod: 100,
113
+ contributionPerPeriod: 100, // optional
92
114
  rate: 0.06,
93
115
  timesPerYear: 12,
94
116
  years: 2,
95
- contributionTiming: "end", // or "begin"
117
+ contributionTiming: "end", // optional ("end" | "begin")
96
118
  });
97
119
  ```
98
120
 
@@ -111,13 +133,16 @@ periodsToReachGoal({
111
133
  rate: 0.06,
112
134
  timesPerYear: 12,
113
135
  contributionPerPeriod: 0, // optional
114
- contributionTiming: "end",
136
+ contributionTiming: "end", // optional
137
+ maxPeriods: 100000, // optional
115
138
  });
116
139
  ```
117
140
 
118
141
  Edge cases:
119
142
  - Returns `Infinity` when the goal is unreachable.
120
143
  - Throws `RangeError` when `rate / timesPerYear <= -1`.
144
+ - Throws `RangeError` when `maxPeriods` is exceeded for contribution-based iteration.
145
+ - Throws `RangeError` when `contributionTiming` is not `"end"` or `"begin"`.
121
146
 
122
147
  - **`rateToReachGoal`** - Rate per period required to reach a target future value in a given number of periods.
123
148
 
@@ -126,11 +151,19 @@ rateToReachGoal({
126
151
  principal: 1000,
127
152
  targetFutureValue: 1500,
128
153
  periods: 24,
129
- contributionPerPeriod: 0,
130
- contributionTiming: "end",
154
+ contributionPerPeriod: 0, // optional
155
+ contributionTiming: "end", // optional
156
+ maxIterations: 100, // optional
157
+ tolerance: 1e-10, // optional
158
+ lowerBound: -0.99, // optional
159
+ upperBound: 10, // optional
131
160
  });
132
161
  ```
133
162
 
163
+ Edge cases:
164
+ - Throws `RangeError` when no root is found within search bounds.
165
+ - Throws `RangeError` when `contributionTiming` is not `"end"` or `"begin"`.
166
+
134
167
  - **`ruleOf72`** - Approximate years to double a lump sum at a given annual rate. Optional `constant: 69` for rule of 69.
135
168
 
136
169
  ```ts
@@ -151,8 +184,8 @@ cagr({ startValue: 1000, endValue: 2000, years: 10 });
151
184
  ```ts
152
185
  irr({
153
186
  cashFlows: [-1000, 300, 400, 500],
154
- guess: 0.1,
155
- maxIterations: 100,
187
+ guess: 0.1, // optional
188
+ maxIterations: 100, // optional
156
189
  lowerBound: -0.99, // optional
157
190
  upperBound: 10, // optional
158
191
  });
@@ -197,7 +230,7 @@ presentValueOfAnnuity({
197
230
  paymentPerPeriod: 100,
198
231
  ratePerPeriod: 0.01,
199
232
  periods: 36,
200
- timing: "end",
233
+ timing: "end", // optional
201
234
  });
202
235
  ```
203
236
 
@@ -208,7 +241,7 @@ paymentFromPresentValue({
208
241
  presentValue: 100000,
209
242
  ratePerPeriod: 0.005,
210
243
  periods: 360,
211
- timing: "end",
244
+ timing: "end", // optional
212
245
  });
213
246
  ```
214
247
 
@@ -225,6 +258,9 @@ loanPayment({
225
258
  });
226
259
  ```
227
260
 
261
+ Edge cases:
262
+ - Throws `RangeError` when `annualRate / paymentsPerYear <= -1`.
263
+
228
264
  - **`amortizationSchedule`** - Full schedule: `{ paymentPerPeriod, schedule, totalPaid, totalInterest }`. Optional `extraPaymentPerPeriod`.
229
265
 
230
266
  ```ts
@@ -233,7 +269,7 @@ amortizationSchedule({
233
269
  annualRate: 0.06,
234
270
  paymentsPerYear: 12,
235
271
  years: 30,
236
- extraPaymentPerPeriod: 50,
272
+ extraPaymentPerPeriod: 50, // optional
237
273
  });
238
274
  ```
239
275
 
@@ -270,7 +306,7 @@ Edge cases:
270
306
 
271
307
  ```ts
272
308
  roundToCurrency({ value: 2.125 }); // 2.13
273
- roundToCurrency({ value: 2.125, decimals: 2, mode: "half-even" });
309
+ roundToCurrency({ value: 2.125, decimals: 2, mode: "half-even" }); // decimals/mode optional
274
310
  ```
275
311
 
276
312
  ## Design
package/dist/index.cjs CHANGED
@@ -187,32 +187,31 @@ function hasPositiveAndNegative(cashFlows) {
187
187
  return false;
188
188
  }
189
189
  function findBracket(fn, lowerBound, upperBound) {
190
- const fLower = fn(lowerBound);
191
- const fUpper = fn(upperBound);
192
- if (!Number.isFinite(fLower) || !Number.isFinite(fUpper)) return void 0;
193
- if (fLower === 0) return { lower: lowerBound, upper: lowerBound };
194
- if (fUpper === 0) return { lower: upperBound, upper: upperBound };
195
- if (fLower * fUpper < 0) return { lower: lowerBound, upper: upperBound };
190
+ const scan = (start, end, segments = 200) => {
191
+ let prevX;
192
+ let prevValue;
193
+ for (let i = 0; i <= segments; i++) {
194
+ const x = start + (end - start) * i / segments;
195
+ const value = fn(x);
196
+ if (!Number.isFinite(value)) continue;
197
+ if (value === 0) return { lower: x, upper: x };
198
+ if (prevX !== void 0 && prevValue !== void 0 && prevValue * value < 0) {
199
+ return { lower: prevX, upper: x };
200
+ }
201
+ prevX = x;
202
+ prevValue = value;
203
+ }
204
+ return void 0;
205
+ };
196
206
  let lower = lowerBound;
197
207
  let upper = upperBound;
198
- let fLo = fLower;
199
- let fHi = fUpper;
200
208
  for (let i = 0; i < 20; i++) {
201
- const nextLower = Math.max(-0.999999999, (lower - 1) / 2);
202
- const fNextLower = fn(nextLower);
203
- if (Number.isFinite(fNextLower) && fNextLower * fHi < 0) {
204
- return { lower: nextLower, upper };
205
- }
206
- if (Number.isFinite(fNextLower)) {
207
- lower = nextLower;
208
- fLo = fNextLower;
209
- }
209
+ const bracket = scan(lower, upper);
210
+ if (bracket !== void 0) return bracket;
211
+ lower = Math.max(-0.999999999, (lower - 1) / 2);
210
212
  upper = upper * 2 + 1;
211
- fHi = fn(upper);
212
- if (!Number.isFinite(fHi)) continue;
213
- if (fLo * fHi < 0) return { lower, upper };
214
213
  }
215
- return void 0;
214
+ return scan(lower, upper, 400);
216
215
  }
217
216
  function irr(params) {
218
217
  const {
@@ -302,6 +301,11 @@ function paymentFromPresentValue(params) {
302
301
  }
303
302
 
304
303
  // src/interest/periodsToReachGoal.ts
304
+ function assertContributionTiming(value, name) {
305
+ if (value !== "end" && value !== "begin") {
306
+ throw new RangeError(`${name} must be "end" or "begin"`);
307
+ }
308
+ }
305
309
  function periodsToReachGoal(params) {
306
310
  const {
307
311
  principal,
@@ -309,13 +313,16 @@ function periodsToReachGoal(params) {
309
313
  rate: rate2,
310
314
  timesPerYear,
311
315
  contributionPerPeriod = 0,
312
- contributionTiming = "end"
316
+ contributionTiming = "end",
317
+ maxPeriods = 1e5
313
318
  } = params;
314
319
  assertNonNegative(principal, "principal");
315
320
  assertNonNegative(targetFutureValue, "targetFutureValue");
316
321
  assertFiniteNumber(rate2, "rate");
317
322
  assertPositive(timesPerYear, "timesPerYear");
318
323
  assertNonNegative(contributionPerPeriod, "contributionPerPeriod");
324
+ assertPositive(maxPeriods, "maxPeriods");
325
+ assertContributionTiming(contributionTiming, "contributionTiming");
319
326
  if (targetFutureValue <= principal) return 0;
320
327
  const r = rate2 / timesPerYear;
321
328
  if (r <= -1) {
@@ -335,13 +342,13 @@ function periodsToReachGoal(params) {
335
342
  }
336
343
  let fv2 = principal;
337
344
  let periods = 0;
338
- const maxPeriods = 1e4;
339
345
  while (fv2 < targetFutureValue && periods < maxPeriods) {
340
346
  if (contributionTiming === "begin") fv2 += contributionPerPeriod;
341
347
  fv2 = fv2 * (1 + r) + (contributionTiming === "end" ? contributionPerPeriod : 0);
342
348
  periods++;
343
349
  }
344
- return fv2 >= targetFutureValue ? periods : Number.POSITIVE_INFINITY;
350
+ if (fv2 >= targetFutureValue) return periods;
351
+ throw new RangeError("maxPeriods exceeded before reaching target");
345
352
  }
346
353
 
347
354
  // src/interest/presentValue.ts
@@ -371,22 +378,46 @@ function presentValueOfAnnuity(params) {
371
378
  }
372
379
 
373
380
  // src/interest/rateToReachGoal.ts
381
+ function assertContributionTiming2(value, name) {
382
+ if (value !== "end" && value !== "begin") {
383
+ throw new RangeError(`${name} must be "end" or "begin"`);
384
+ }
385
+ }
374
386
  function rateToReachGoal(params) {
375
387
  const {
376
388
  principal,
377
389
  targetFutureValue,
378
390
  periods,
379
391
  contributionPerPeriod = 0,
380
- contributionTiming = "end"
392
+ contributionTiming = "end",
393
+ lowerBound = -0.99,
394
+ upperBound = 10,
395
+ maxIterations = 100,
396
+ tolerance = 1e-10
381
397
  } = params;
382
398
  assertNonNegative(principal, "principal");
383
399
  assertNonNegative(targetFutureValue, "targetFutureValue");
384
400
  assertPositive(periods, "periods");
385
401
  assertNonNegative(contributionPerPeriod, "contributionPerPeriod");
386
- if (periods === 0) return 0;
402
+ assertFiniteNumber(lowerBound, "lowerBound");
403
+ assertFiniteNumber(upperBound, "upperBound");
404
+ assertFiniteNumber(maxIterations, "maxIterations");
405
+ assertFiniteNumber(tolerance, "tolerance");
406
+ assertContributionTiming2(contributionTiming, "contributionTiming");
407
+ if (lowerBound <= -1) throw new RangeError("lowerBound must be > -1");
408
+ if (upperBound <= lowerBound) throw new RangeError("upperBound must be greater than lowerBound");
409
+ if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
410
+ throw new RangeError("maxIterations must be a positive integer");
411
+ }
412
+ if (tolerance <= 0) throw new RangeError("tolerance must be > 0");
387
413
  if (targetFutureValue <= principal && contributionPerPeriod === 0) return 0;
388
414
  if (contributionPerPeriod === 0) {
389
415
  if (targetFutureValue <= principal) return 0;
416
+ if (principal === 0) {
417
+ throw new RangeError(
418
+ "rateToReachGoal is undefined when principal is 0 and contributions are 0"
419
+ );
420
+ }
390
421
  return (targetFutureValue / principal) ** (1 / periods) - 1;
391
422
  }
392
423
  const dueFactor = contributionTiming === "begin" ? 1 : 0;
@@ -404,23 +435,56 @@ function rateToReachGoal(params) {
404
435
  };
405
436
  const initialGuess = (targetFutureValue / (principal + contributionPerPeriod * periods)) ** (1 / periods) - 1;
406
437
  const newton = newtonRaphson({
407
- initialGuess: Number.isFinite(initialGuess) ? Math.max(initialGuess, -0.99) : 0.01,
438
+ initialGuess: Number.isFinite(initialGuess) ? Math.max(initialGuess, lowerBound) : 0.01,
408
439
  fn,
409
440
  derivative,
410
- tolerance: 1e-10,
411
- maxIterations: 100,
412
- min: -0.99,
413
- max: 10
441
+ tolerance,
442
+ maxIterations,
443
+ min: lowerBound,
444
+ max: upperBound
414
445
  });
415
446
  if (newton !== void 0) return newton;
447
+ const bracket = findBracket2(fn, lowerBound, upperBound);
448
+ if (bracket === void 0) {
449
+ throw new RangeError("rateToReachGoal did not converge within search bounds");
450
+ }
451
+ if (bracket.lower === bracket.upper) return bracket.lower;
416
452
  const bisected = bisection({
417
453
  fn,
418
- lower: -0.99,
419
- upper: 10,
420
- tolerance: 1e-10,
421
- maxIterations: 200
454
+ lower: bracket.lower,
455
+ upper: bracket.upper,
456
+ tolerance,
457
+ maxIterations: maxIterations * 2
422
458
  });
423
- return bisected ?? Number.NaN;
459
+ if (bisected !== void 0) return bisected;
460
+ throw new RangeError("rateToReachGoal did not converge");
461
+ }
462
+ function findBracket2(fn, lowerBound, upperBound) {
463
+ const scan = (start, end, segments = 200) => {
464
+ let prevX;
465
+ let prevValue;
466
+ for (let i = 0; i <= segments; i++) {
467
+ const x = start + (end - start) * i / segments;
468
+ const value = fn(x);
469
+ if (!Number.isFinite(value)) continue;
470
+ if (value === 0) return { lower: x, upper: x };
471
+ if (prevX !== void 0 && prevValue !== void 0 && prevValue * value < 0) {
472
+ return { lower: prevX, upper: x };
473
+ }
474
+ prevX = x;
475
+ prevValue = value;
476
+ }
477
+ return void 0;
478
+ };
479
+ let lower = lowerBound;
480
+ let upper = upperBound;
481
+ for (let i = 0; i < 20; i++) {
482
+ const bracket = scan(lower, upper);
483
+ if (bracket !== void 0) return bracket;
484
+ lower = Math.max(-0.999999999, (lower - 1) / 2);
485
+ upper = upper * 2 + 1;
486
+ }
487
+ return scan(lower, upper, 400);
424
488
  }
425
489
 
426
490
  // src/interest/realReturn.ts
@@ -450,8 +514,12 @@ function annuityDueFactor(ratePerPeriod, timing) {
450
514
  return timing === "begin" ? 1 + ratePerPeriod : 1;
451
515
  }
452
516
  function futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing) {
517
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
518
+ throw new RangeError("ratePerPeriod must be > -1");
519
+ }
453
520
  if (ratePerPeriod === 0) return -(presentValue2 + payment * periods);
454
521
  const growth = (1 + ratePerPeriod) ** periods;
522
+ if (!Number.isFinite(growth) || growth <= 0) return Number.NaN;
455
523
  const paymentFv = payment * ((growth - 1) / ratePerPeriod) * annuityDueFactor(ratePerPeriod, timing);
456
524
  return -(presentValue2 * growth + paymentFv);
457
525
  }
@@ -462,7 +530,11 @@ function fv(params) {
462
530
  assertFiniteNumber(payment, "payment");
463
531
  assertFiniteNumber(presentValue2, "presentValue");
464
532
  assertTiming(timing, "timing");
465
- return futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing);
533
+ const value = futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing);
534
+ if (!Number.isFinite(value)) {
535
+ throw new RangeError("Numerical instability for given inputs");
536
+ }
537
+ return value;
466
538
  }
467
539
  function pv(params) {
468
540
  const { ratePerPeriod, periods, payment = 0, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -471,10 +543,20 @@ function pv(params) {
471
543
  assertFiniteNumber(payment, "payment");
472
544
  assertFiniteNumber(futureValue2, "futureValue");
473
545
  assertTiming(timing, "timing");
546
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
547
+ throw new RangeError("ratePerPeriod must be > -1");
548
+ }
474
549
  if (ratePerPeriod === 0) return -(futureValue2 + payment * periods);
475
550
  const growth = (1 + ratePerPeriod) ** periods;
551
+ if (!Number.isFinite(growth) || growth <= 0) {
552
+ throw new RangeError("Numerical instability for given ratePerPeriod/periods");
553
+ }
476
554
  const paymentPv = payment * (1 - 1 / growth) / ratePerPeriod * annuityDueFactor(ratePerPeriod, timing);
477
- return -futureValue2 / growth - paymentPv;
555
+ const value = -futureValue2 / growth - paymentPv;
556
+ if (!Number.isFinite(value)) {
557
+ throw new RangeError("Numerical instability for given inputs");
558
+ }
559
+ return value;
478
560
  }
479
561
  function pmt(params) {
480
562
  const { ratePerPeriod, periods, presentValue: presentValue2, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -483,11 +565,21 @@ function pmt(params) {
483
565
  assertFiniteNumber(presentValue2, "presentValue");
484
566
  assertFiniteNumber(futureValue2, "futureValue");
485
567
  assertTiming(timing, "timing");
568
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
569
+ throw new RangeError("ratePerPeriod must be > -1");
570
+ }
486
571
  if (ratePerPeriod === 0) return -(presentValue2 + futureValue2) / periods;
487
572
  const growth = (1 + ratePerPeriod) ** periods;
573
+ if (!Number.isFinite(growth) || growth <= 0) {
574
+ throw new RangeError("Numerical instability for given ratePerPeriod/periods");
575
+ }
488
576
  const numerator = -(futureValue2 + presentValue2 * growth) * ratePerPeriod;
489
577
  const denominator = (growth - 1) * annuityDueFactor(ratePerPeriod, timing);
490
- return numerator / denominator;
578
+ const value = numerator / denominator;
579
+ if (!Number.isFinite(value)) {
580
+ throw new RangeError("Numerical instability for given inputs");
581
+ }
582
+ return value;
491
583
  }
492
584
  function nper(params) {
493
585
  const { ratePerPeriod, payment, presentValue: presentValue2, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -608,13 +700,21 @@ function findRateBracket(fn, lowerBound, upperBound) {
608
700
  function loanPayment(params) {
609
701
  const { principal, annualRate, paymentsPerYear, years } = params;
610
702
  assertNonNegative(principal, "principal");
703
+ assertFiniteNumber(annualRate, "annualRate");
611
704
  assertPositive(paymentsPerYear, "paymentsPerYear");
612
705
  assertNonNegative(years, "years");
613
706
  const n = Math.round(paymentsPerYear * years);
614
707
  if (n === 0) return 0;
615
708
  const r = annualRate / paymentsPerYear;
709
+ if (r <= -1) {
710
+ throw new RangeError("annualRate / paymentsPerYear must be > -1");
711
+ }
616
712
  if (r === 0) return principal / n;
617
- return r * principal / (1 - (1 + r) ** -n);
713
+ const value = r * principal / (1 - (1 + r) ** -n);
714
+ if (!Number.isFinite(value)) {
715
+ throw new RangeError("Numerical instability for given inputs");
716
+ }
717
+ return value;
618
718
  }
619
719
 
620
720
  // src/loans/amortizationSchedule.ts
@@ -712,13 +812,16 @@ function roundToCurrency(params) {
712
812
  const factor = 10 ** d;
713
813
  if (mode === "half-even") {
714
814
  const scaled = value * factor;
715
- const rounded = Math.round(scaled);
716
- const remainder = Math.abs(scaled - rounded);
717
- if (remainder === 0.5) {
718
- const down = Math.floor(scaled);
719
- return (down % 2 === 0 ? down : down + 1) / factor;
815
+ const sign = scaled < 0 ? -1 : 1;
816
+ const absScaled = Math.abs(scaled);
817
+ const floorAbs = Math.floor(absScaled);
818
+ const fraction = absScaled - floorAbs;
819
+ const epsilon = 1e-12;
820
+ if (Math.abs(fraction - 0.5) <= epsilon) {
821
+ const nearestEven = floorAbs % 2 === 0 ? floorAbs : floorAbs + 1;
822
+ return sign * nearestEven / factor;
720
823
  }
721
- return rounded / factor;
824
+ return Math.round(scaled) / factor;
722
825
  }
723
826
  return Math.round(value * factor) / factor;
724
827
  }