@pyverret/ratejs 1.1.0 → 1.1.1

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
@@ -62,6 +62,7 @@ const impliedRate = rate({
62
62
  Edge cases:
63
63
  - `nper` throws `RangeError` when `ratePerPeriod <= -1`.
64
64
  - `rate` throws `RangeError` when no root is found within search bounds.
65
+ - `pmt`, `pv`, and `fv` throw `RangeError` when `ratePerPeriod <= -1`.
65
66
 
66
67
  ### Interest & growth
67
68
 
@@ -118,6 +119,7 @@ periodsToReachGoal({
118
119
  Edge cases:
119
120
  - Returns `Infinity` when the goal is unreachable.
120
121
  - Throws `RangeError` when `rate / timesPerYear <= -1`.
122
+ - Throws `RangeError` when `maxPeriods` is exceeded for contribution-based iteration.
121
123
 
122
124
  - **`rateToReachGoal`** - Rate per period required to reach a target future value in a given number of periods.
123
125
 
@@ -128,9 +130,14 @@ rateToReachGoal({
128
130
  periods: 24,
129
131
  contributionPerPeriod: 0,
130
132
  contributionTiming: "end",
133
+ lowerBound: -0.99, // optional
134
+ upperBound: 10, // optional
131
135
  });
132
136
  ```
133
137
 
138
+ Edge cases:
139
+ - Throws `RangeError` when no root is found within search bounds.
140
+
134
141
  - **`ruleOf72`** - Approximate years to double a lump sum at a given annual rate. Optional `constant: 69` for rule of 69.
135
142
 
136
143
  ```ts
@@ -225,6 +232,9 @@ loanPayment({
225
232
  });
226
233
  ```
227
234
 
235
+ Edge cases:
236
+ - Throws `RangeError` when `annualRate / paymentsPerYear <= -1`.
237
+
228
238
  - **`amortizationSchedule`** - Full schedule: `{ paymentPerPeriod, schedule, totalPaid, totalInterest }`. Optional `extraPaymentPerPeriod`.
229
239
 
230
240
  ```ts
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 {
@@ -309,13 +308,15 @@ function periodsToReachGoal(params) {
309
308
  rate: rate2,
310
309
  timesPerYear,
311
310
  contributionPerPeriod = 0,
312
- contributionTiming = "end"
311
+ contributionTiming = "end",
312
+ maxPeriods = 1e5
313
313
  } = params;
314
314
  assertNonNegative(principal, "principal");
315
315
  assertNonNegative(targetFutureValue, "targetFutureValue");
316
316
  assertFiniteNumber(rate2, "rate");
317
317
  assertPositive(timesPerYear, "timesPerYear");
318
318
  assertNonNegative(contributionPerPeriod, "contributionPerPeriod");
319
+ assertPositive(maxPeriods, "maxPeriods");
319
320
  if (targetFutureValue <= principal) return 0;
320
321
  const r = rate2 / timesPerYear;
321
322
  if (r <= -1) {
@@ -335,13 +336,13 @@ function periodsToReachGoal(params) {
335
336
  }
336
337
  let fv2 = principal;
337
338
  let periods = 0;
338
- const maxPeriods = 1e4;
339
339
  while (fv2 < targetFutureValue && periods < maxPeriods) {
340
340
  if (contributionTiming === "begin") fv2 += contributionPerPeriod;
341
341
  fv2 = fv2 * (1 + r) + (contributionTiming === "end" ? contributionPerPeriod : 0);
342
342
  periods++;
343
343
  }
344
- return fv2 >= targetFutureValue ? periods : Number.POSITIVE_INFINITY;
344
+ if (fv2 >= targetFutureValue) return periods;
345
+ throw new RangeError("maxPeriods exceeded before reaching target");
345
346
  }
346
347
 
347
348
  // src/interest/presentValue.ts
@@ -377,16 +378,35 @@ function rateToReachGoal(params) {
377
378
  targetFutureValue,
378
379
  periods,
379
380
  contributionPerPeriod = 0,
380
- contributionTiming = "end"
381
+ contributionTiming = "end",
382
+ lowerBound = -0.99,
383
+ upperBound = 10,
384
+ maxIterations = 100,
385
+ tolerance = 1e-10
381
386
  } = params;
382
387
  assertNonNegative(principal, "principal");
383
388
  assertNonNegative(targetFutureValue, "targetFutureValue");
384
389
  assertPositive(periods, "periods");
385
390
  assertNonNegative(contributionPerPeriod, "contributionPerPeriod");
391
+ assertFiniteNumber(lowerBound, "lowerBound");
392
+ assertFiniteNumber(upperBound, "upperBound");
393
+ assertFiniteNumber(maxIterations, "maxIterations");
394
+ assertFiniteNumber(tolerance, "tolerance");
395
+ if (lowerBound <= -1) throw new RangeError("lowerBound must be > -1");
396
+ if (upperBound <= lowerBound) throw new RangeError("upperBound must be greater than lowerBound");
397
+ if (!Number.isInteger(maxIterations) || maxIterations <= 0) {
398
+ throw new RangeError("maxIterations must be a positive integer");
399
+ }
400
+ if (tolerance <= 0) throw new RangeError("tolerance must be > 0");
386
401
  if (periods === 0) return 0;
387
402
  if (targetFutureValue <= principal && contributionPerPeriod === 0) return 0;
388
403
  if (contributionPerPeriod === 0) {
389
404
  if (targetFutureValue <= principal) return 0;
405
+ if (principal === 0) {
406
+ throw new RangeError(
407
+ "rateToReachGoal is undefined when principal is 0 and contributions are 0"
408
+ );
409
+ }
390
410
  return (targetFutureValue / principal) ** (1 / periods) - 1;
391
411
  }
392
412
  const dueFactor = contributionTiming === "begin" ? 1 : 0;
@@ -404,23 +424,56 @@ function rateToReachGoal(params) {
404
424
  };
405
425
  const initialGuess = (targetFutureValue / (principal + contributionPerPeriod * periods)) ** (1 / periods) - 1;
406
426
  const newton = newtonRaphson({
407
- initialGuess: Number.isFinite(initialGuess) ? Math.max(initialGuess, -0.99) : 0.01,
427
+ initialGuess: Number.isFinite(initialGuess) ? Math.max(initialGuess, lowerBound) : 0.01,
408
428
  fn,
409
429
  derivative,
410
- tolerance: 1e-10,
411
- maxIterations: 100,
412
- min: -0.99,
413
- max: 10
430
+ tolerance,
431
+ maxIterations,
432
+ min: lowerBound,
433
+ max: upperBound
414
434
  });
415
435
  if (newton !== void 0) return newton;
436
+ const bracket = findBracket2(fn, lowerBound, upperBound);
437
+ if (bracket === void 0) {
438
+ throw new RangeError("rateToReachGoal did not converge within search bounds");
439
+ }
440
+ if (bracket.lower === bracket.upper) return bracket.lower;
416
441
  const bisected = bisection({
417
442
  fn,
418
- lower: -0.99,
419
- upper: 10,
420
- tolerance: 1e-10,
421
- maxIterations: 200
443
+ lower: bracket.lower,
444
+ upper: bracket.upper,
445
+ tolerance,
446
+ maxIterations: maxIterations * 2
422
447
  });
423
- return bisected ?? Number.NaN;
448
+ if (bisected !== void 0) return bisected;
449
+ throw new RangeError("rateToReachGoal did not converge");
450
+ }
451
+ function findBracket2(fn, lowerBound, upperBound) {
452
+ const scan = (start, end, segments = 200) => {
453
+ let prevX;
454
+ let prevValue;
455
+ for (let i = 0; i <= segments; i++) {
456
+ const x = start + (end - start) * i / segments;
457
+ const value = fn(x);
458
+ if (!Number.isFinite(value)) continue;
459
+ if (value === 0) return { lower: x, upper: x };
460
+ if (prevX !== void 0 && prevValue !== void 0 && prevValue * value < 0) {
461
+ return { lower: prevX, upper: x };
462
+ }
463
+ prevX = x;
464
+ prevValue = value;
465
+ }
466
+ return void 0;
467
+ };
468
+ let lower = lowerBound;
469
+ let upper = upperBound;
470
+ for (let i = 0; i < 20; i++) {
471
+ const bracket = scan(lower, upper);
472
+ if (bracket !== void 0) return bracket;
473
+ lower = Math.max(-0.999999999, (lower - 1) / 2);
474
+ upper = upper * 2 + 1;
475
+ }
476
+ return scan(lower, upper, 400);
424
477
  }
425
478
 
426
479
  // src/interest/realReturn.ts
@@ -450,8 +503,12 @@ function annuityDueFactor(ratePerPeriod, timing) {
450
503
  return timing === "begin" ? 1 + ratePerPeriod : 1;
451
504
  }
452
505
  function futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing) {
506
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
507
+ throw new RangeError("ratePerPeriod must be > -1");
508
+ }
453
509
  if (ratePerPeriod === 0) return -(presentValue2 + payment * periods);
454
510
  const growth = (1 + ratePerPeriod) ** periods;
511
+ if (!Number.isFinite(growth) || growth <= 0) return Number.NaN;
455
512
  const paymentFv = payment * ((growth - 1) / ratePerPeriod) * annuityDueFactor(ratePerPeriod, timing);
456
513
  return -(presentValue2 * growth + paymentFv);
457
514
  }
@@ -462,7 +519,11 @@ function fv(params) {
462
519
  assertFiniteNumber(payment, "payment");
463
520
  assertFiniteNumber(presentValue2, "presentValue");
464
521
  assertTiming(timing, "timing");
465
- return futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing);
522
+ const value = futureValueFromCashFlows(ratePerPeriod, periods, payment, presentValue2, timing);
523
+ if (!Number.isFinite(value)) {
524
+ throw new RangeError("Numerical instability for given inputs");
525
+ }
526
+ return value;
466
527
  }
467
528
  function pv(params) {
468
529
  const { ratePerPeriod, periods, payment = 0, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -471,10 +532,20 @@ function pv(params) {
471
532
  assertFiniteNumber(payment, "payment");
472
533
  assertFiniteNumber(futureValue2, "futureValue");
473
534
  assertTiming(timing, "timing");
535
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
536
+ throw new RangeError("ratePerPeriod must be > -1");
537
+ }
474
538
  if (ratePerPeriod === 0) return -(futureValue2 + payment * periods);
475
539
  const growth = (1 + ratePerPeriod) ** periods;
540
+ if (!Number.isFinite(growth) || growth <= 0) {
541
+ throw new RangeError("Numerical instability for given ratePerPeriod/periods");
542
+ }
476
543
  const paymentPv = payment * (1 - 1 / growth) / ratePerPeriod * annuityDueFactor(ratePerPeriod, timing);
477
- return -futureValue2 / growth - paymentPv;
544
+ const value = -futureValue2 / growth - paymentPv;
545
+ if (!Number.isFinite(value)) {
546
+ throw new RangeError("Numerical instability for given inputs");
547
+ }
548
+ return value;
478
549
  }
479
550
  function pmt(params) {
480
551
  const { ratePerPeriod, periods, presentValue: presentValue2, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -483,11 +554,21 @@ function pmt(params) {
483
554
  assertFiniteNumber(presentValue2, "presentValue");
484
555
  assertFiniteNumber(futureValue2, "futureValue");
485
556
  assertTiming(timing, "timing");
557
+ if (ratePerPeriodIsInvalidForDomain(ratePerPeriod)) {
558
+ throw new RangeError("ratePerPeriod must be > -1");
559
+ }
486
560
  if (ratePerPeriod === 0) return -(presentValue2 + futureValue2) / periods;
487
561
  const growth = (1 + ratePerPeriod) ** periods;
562
+ if (!Number.isFinite(growth) || growth <= 0) {
563
+ throw new RangeError("Numerical instability for given ratePerPeriod/periods");
564
+ }
488
565
  const numerator = -(futureValue2 + presentValue2 * growth) * ratePerPeriod;
489
566
  const denominator = (growth - 1) * annuityDueFactor(ratePerPeriod, timing);
490
- return numerator / denominator;
567
+ const value = numerator / denominator;
568
+ if (!Number.isFinite(value)) {
569
+ throw new RangeError("Numerical instability for given inputs");
570
+ }
571
+ return value;
491
572
  }
492
573
  function nper(params) {
493
574
  const { ratePerPeriod, payment, presentValue: presentValue2, futureValue: futureValue2 = 0, timing = "end" } = params;
@@ -608,13 +689,21 @@ function findRateBracket(fn, lowerBound, upperBound) {
608
689
  function loanPayment(params) {
609
690
  const { principal, annualRate, paymentsPerYear, years } = params;
610
691
  assertNonNegative(principal, "principal");
692
+ assertFiniteNumber(annualRate, "annualRate");
611
693
  assertPositive(paymentsPerYear, "paymentsPerYear");
612
694
  assertNonNegative(years, "years");
613
695
  const n = Math.round(paymentsPerYear * years);
614
696
  if (n === 0) return 0;
615
697
  const r = annualRate / paymentsPerYear;
698
+ if (r <= -1) {
699
+ throw new RangeError("annualRate / paymentsPerYear must be > -1");
700
+ }
616
701
  if (r === 0) return principal / n;
617
- return r * principal / (1 - (1 + r) ** -n);
702
+ const value = r * principal / (1 - (1 + r) ** -n);
703
+ if (!Number.isFinite(value)) {
704
+ throw new RangeError("Numerical instability for given inputs");
705
+ }
706
+ return value;
618
707
  }
619
708
 
620
709
  // src/loans/amortizationSchedule.ts
@@ -712,13 +801,16 @@ function roundToCurrency(params) {
712
801
  const factor = 10 ** d;
713
802
  if (mode === "half-even") {
714
803
  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;
804
+ const sign = scaled < 0 ? -1 : 1;
805
+ const absScaled = Math.abs(scaled);
806
+ const floorAbs = Math.floor(absScaled);
807
+ const fraction = absScaled - floorAbs;
808
+ const epsilon = 1e-12;
809
+ if (Math.abs(fraction - 0.5) <= epsilon) {
810
+ const nearestEven = floorAbs % 2 === 0 ? floorAbs : floorAbs + 1;
811
+ return sign * nearestEven / factor;
720
812
  }
721
- return rounded / factor;
813
+ return Math.round(scaled) / factor;
722
814
  }
723
815
  return Math.round(value * factor) / factor;
724
816
  }