fundamental-js 1.2.0 → 1.3.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  A comprehensive, zero-dependency TypeScript financial calculator library for investing, loans, and personal finance.
4
4
 
5
- All functions are **pure**, **deterministic**, and work in both **Node.js** and **browser** environments. Rates are expressed as decimals (0.12 = 12%).
5
+ All functions are **pure**, **deterministic**, and work in both **Node.js** and **browser** environments.
6
6
 
7
7
  ## Install
8
8
 
@@ -23,7 +23,6 @@ import {
23
23
 
24
24
  // EMI for a ₹25L loan at 8.5% for 20 years
25
25
  const loan = emi(2500000, 0.085, 240);
26
- console.log(loan);
27
26
  // { emi: 21698.69, totalPayment: 5207685.73, totalInterest: 2707685.73 }
28
27
 
29
28
  // SIP: ₹25,000/month at 12% for 15 years
@@ -32,7 +31,6 @@ const sip = sipFutureValue({
32
31
  annualRate: 0.12,
33
32
  months: 180,
34
33
  });
35
- console.log(sip);
36
34
  // { futureValue: 12649741.45, totalInvested: 4500000, estimatedGains: 8149741.45 }
37
35
 
38
36
  // Step-up SIP: ₹25,000/month, 10% annual step-up
@@ -40,18 +38,17 @@ const stepUp = stepUpSipFutureValue({
40
38
  monthlyInvestment: 25000,
41
39
  annualRate: 0.12,
42
40
  months: 180,
43
- stepUpPercentAnnual: 10,
41
+ stepUpPercentAnnual: 0.10,
44
42
  });
45
- console.log(stepUp);
46
43
 
47
- // SWP: ₹1Cr corpus, ₹50K/month withdrawal at 8%
44
+ // SWP: ₹1Cr corpus, ₹50K/month withdrawal at 8%, 6% inflation
48
45
  const withdrawal = swpPlan({
49
46
  initialCorpus: 10000000,
50
47
  monthlyWithdrawal: 50000,
51
48
  annualRate: 0.08,
52
49
  months: 300,
50
+ inflationRateAnnual: 0.06,
53
51
  });
54
- console.log(withdrawal.endingCorpus, withdrawal.totalWithdrawn);
55
52
 
56
53
  // XIRR: Irregular cashflows with dates
57
54
  const result = xirr([
@@ -59,67 +56,105 @@ const result = xirr([
59
56
  { date: new Date("2020-06-15"), amount: 5000 },
60
57
  { date: new Date("2021-01-01"), amount: 115000 },
61
58
  ]);
62
- console.log(result); // ~0.1975 (19.75%)
59
+ // ~0.1975 (19.75%)
63
60
  ```
64
61
 
62
+ ## Conventions
63
+
64
+ All rates and percentages are expressed as **decimals** throughout the library:
65
+
66
+ | Meaning | Value | NOT |
67
+ |---------|-------|-----|
68
+ | 12% annual return | `0.12` | `12` |
69
+ | 8.5% interest rate | `0.085` | `8.5` |
70
+ | 10% annual step-up | `0.10` | `10` |
71
+ | 6% inflation | `0.06` | `6` |
72
+
73
+ Other conventions:
74
+ - **Period type**: `0` = end-of-period (ordinary annuity), `1` = beginning-of-period (annuity due)
75
+ - **Day count**: XNPV/XIRR default to ACT/365; pass `"ACT/360"` to override
76
+ - **Cash-flow sign**: Outflows are negative, inflows are positive (Excel convention for PV/FV/PMT/NPER/RATE)
77
+
65
78
  ## API Reference
66
79
 
67
- ### Time Value of Money (Excel-compatible)
80
+ ### Time Value of Money
68
81
 
69
- | Function | Description |
70
- |----------|-------------|
71
- | `pv(rate, nper, pmt, fv?, type?)` | Present Value |
72
- | `fv(rate, nper, pmt, pv?, type?)` | Future Value |
73
- | `pmt(rate, nper, pv, fv?, type?)` | Payment per period |
74
- | `nper(rate, pmt, pv, fv?, type?)` | Number of periods |
75
- | `rate(nper, pmt, pv, fv?, type?, guess?)` | Solve for interest rate (iterative) |
76
- | `npv(rate, cashflows)` | Net Present Value |
77
- | `irr(cashflows, guess?)` | Internal Rate of Return (Newton-Raphson + bisection fallback) |
78
- | `xnpv(rate, cashflows, dayCount?)` | NPV with irregular dates |
79
- | `xirr(cashflows, guess?, dayCount?)` | IRR with irregular dates |
82
+ Excel-compatible sign convention: cash you pay out is negative, cash you receive is positive.
83
+
84
+ | Function | Formula | Description |
85
+ |----------|---------|-------------|
86
+ | `pv(rate, nper, pmt, fv?, type?)` | PV = −[PMT·(1+r·type)·((1+r)ⁿ−1)/r + FV] / (1+r)ⁿ | Present Value |
87
+ | `fv(rate, nper, pmt, pv?, type?)` | FV = −[PV·(1+r)ⁿ + PMT·(1+r·type)·((1+r)ⁿ−1)/r] | Future Value |
88
+ | `pmt(rate, nper, pv, fv?, type?)` | PMT = −[PV·(1+r)ⁿ + FV]·r / [(1+r·type)·((1+r)ⁿ−1)] | Payment per period |
89
+ | `nper(rate, pmt, pv, fv?, type?)` | NPER = ln[(PMT·(1+r·type) − FV·r) / (PMT·(1+r·type) + PV·r)] / ln(1+r) | Number of periods |
90
+ | `rate(nper, pmt, pv, fv?, type?, guess?)` | Newton-Raphson iterative solver | Solve for periodic rate |
91
+
92
+ **Parameters:**
93
+ - `rate` — periodic interest rate as decimal (e.g., monthly rate = annual / 12)
94
+ - `nper` — total number of compounding periods
95
+ - `type` — `0` = end of period (default), `1` = beginning of period
96
+
97
+ ### Cash-Flow Analysis
98
+
99
+ | Function | Formula | Description |
100
+ |----------|---------|-------------|
101
+ | `npv(rate, cashflows)` | NPV = Σ CFᵢ / (1+r)ⁱ, i = 0…n | Net Present Value (textbook-style: CF₀ at face value) |
102
+ | `irr(cashflows, guess?)` | Solves NPV = 0 | Internal Rate of Return |
103
+ | `xnpv(rate, cashflows, dayCount?)` | XNPV = Σ CFᵢ / (1+r)^(dᵢ/365) | NPV with irregular dates |
104
+ | `xirr(cashflows, guess?, dayCount?)` | Solves XNPV = 0 | IRR with irregular dates |
105
+
106
+ **Note on NPV:** This is the textbook NPV where `cashflows[0]` is at time 0 (not discounted). Excel's `NPV()` discounts from period 1; to replicate Excel in this library, do: `npv(rate, [0, ...cashflows])` or `cashflows[0] + npv(rate, cashflows.slice(1))`.
107
+
108
+ **IRR/XIRR validation:** Both functions throw an `Error` if cash flows do not contain at least one positive and one negative value (no sign change means no valid rate exists).
80
109
 
81
110
  **Parameters:**
82
- - `rate` periodic interest rate as decimal
83
- - `nper` total number of periods
84
- - `pmt` — payment per period
85
- - `type` — 0 = end of period (default), 1 = beginning of period
86
- - `cashflows` for xnpv/xirr: `{ date: Date, amount: number }[]`
111
+ - `cashflows` for NPV/IRR: `number[]`
112
+ - `cashflows` for XNPV/XIRR: `{ date: Date, amount: number }[]`
87
113
  - `dayCount` — `"ACT/365"` (default) or `"ACT/360"`
88
114
 
89
115
  ### Returns Analysis
90
116
 
91
- | Function | Description |
92
- |----------|-------------|
93
- | `absoluteReturn(beginValue, endValue)` | Simple return as a decimal |
94
- | `cagr(beginValue, endValue, years)` | Compound Annual Growth Rate |
95
- | `annualizedReturn(beginValue, endValue, days)` | Annualized return using ACT/365 |
96
- | `trailingReturn(series, windowDays)` | Trailing return over a rolling window |
117
+ | Function | Formula | Description |
118
+ |----------|---------|-------------|
119
+ | `absoluteReturn(beginValue, endValue)` | (end − begin) / begin | Simple return as decimal |
120
+ | `cagr(beginValue, endValue, years)` | (end / begin)^(1/years) − 1 | Compound Annual Growth Rate |
121
+ | `annualizedReturn(beginValue, endValue, days)` | (end / begin)^(365/days) − 1 | Annualized return using ACT/365 |
122
+ | `trailingReturn(series, windowDays)` | Simple return from nearest point to cutoff | Trailing return over a rolling window |
97
123
 
98
124
  **Parameters:**
99
125
  - `series` for trailingReturn: `{ date: Date, value: number }[]`
100
- - `windowDays` — lookback window in days
126
+ - `windowDays` — lookback window in calendar days
101
127
 
102
128
  ### SIP / Lumpsum / SWP
103
129
 
104
130
  | Function | Description |
105
131
  |----------|-------------|
106
- | `sipFutureValue(params)` | SIP future value with optional step-up |
107
- | `stepUpSipFutureValue(params)` | SIP with required annual step-up |
108
- | `lumpsumFutureValue(principal, annualRate, years)` | One-time investment growth |
109
- | `swpPlan(params)` | Systematic Withdrawal Plan with schedule |
110
- | `inflationAdjustedValue(value, inflationRate, years)` | Purchasing power after inflation |
111
- | `realReturn(nominalRate, inflationRate)` | Fisher equation real return |
132
+ | `sipFutureValue(params)` | SIP future value with optional annual step-up |
133
+ | `stepUpSipFutureValue(params)` | SIP with required annual step-up (delegates to sipFutureValue) |
134
+ | `lumpsumFutureValue(principal, annualRate, years)` | FV = P × (1 + r)^t |
135
+ | `swpPlan(params)` | Systematic Withdrawal Plan with month-by-month schedule |
136
+ | `inflationAdjustedValue(value, inflationRate, years)` | value / (1 + inflation)^years |
137
+ | `realReturn(nominalRate, inflationRate)` | Fisher equation: (1 + nominal) / (1 + inflation) − 1 |
112
138
 
113
139
  **sipFutureValue params:**
114
140
  ```ts
115
141
  {
116
142
  monthlyInvestment: number,
117
- annualRate: number, // decimal (0.12 = 12%)
143
+ annualRate: number, // 0.12 = 12%
118
144
  months: number,
119
- stepUpPercentAnnual?: number, // e.g. 10 for 10%
145
+ stepUpPercentAnnual?: number, // 0.10 = 10% annual increase
120
146
  investmentAt?: "begin" | "end"
121
147
  }
122
148
  ```
149
+
150
+ **SIP formula (end-of-period, no step-up):**
151
+ FV = P × [(1+r)ⁿ − 1] / r, where r = annualRate / 12
152
+
153
+ **SIP formula (beginning-of-period):**
154
+ FV = P × (1+r) × [(1+r)ⁿ − 1] / r
155
+
156
+ With step-up: monthly investment increases by `stepUpPercentAnnual` every 12 months, computed iteratively.
157
+
123
158
  Returns: `{ futureValue, totalInvested, estimatedGains }`
124
159
 
125
160
  **swpPlan params:**
@@ -127,9 +162,9 @@ Returns: `{ futureValue, totalInvested, estimatedGains }`
127
162
  {
128
163
  initialCorpus: number,
129
164
  monthlyWithdrawal: number,
130
- annualRate: number,
165
+ annualRate: number, // 0.08 = 8%
131
166
  months: number,
132
- inflationRateAnnual?: number, // e.g. 6 for 6%
167
+ inflationRateAnnual?: number, // 0.06 = 6% (withdrawal increases annually)
133
168
  withdrawalAt?: "begin" | "end"
134
169
  }
135
170
  ```
@@ -137,18 +172,18 @@ Returns: `{ endingCorpus, totalWithdrawn, schedule[] }`
137
172
 
138
173
  ### Goal Planning
139
174
 
140
- | Function | Description |
141
- |----------|-------------|
142
- | `requiredMonthlyInvestmentForGoal(params)` | Required monthly SIP for a target amount |
143
- | `requiredLumpsumForGoal(goal, annualRate, years)` | Required lumpsum for a target amount |
175
+ | Function | Formula | Description |
176
+ |----------|---------|-------------|
177
+ | `requiredMonthlyInvestmentForGoal(params)` | Binary search over sipFutureValue | Required monthly SIP for a target amount |
178
+ | `requiredLumpsumForGoal(goal, annualRate, years)` | goal / (1 + r)^years | Required lumpsum (present value of goal) |
144
179
 
145
180
  **requiredMonthlyInvestmentForGoal params:**
146
181
  ```ts
147
182
  {
148
183
  goalAmountFuture: number,
149
- annualRate: number,
184
+ annualRate: number, // 0.12 = 12%
150
185
  months: number,
151
- stepUpPercentAnnual?: number,
186
+ stepUpPercentAnnual?: number, // 0.10 = 10%
152
187
  investmentAt?: "begin" | "end"
153
188
  }
154
189
  ```
@@ -156,13 +191,15 @@ Returns: `{ requiredMonthlyInvestment, assumptions }`
156
191
 
157
192
  ### Loans
158
193
 
159
- | Function | Description |
160
- |----------|-------------|
161
- | `emi(principal, annualRate, months)` | Equated Monthly Installment |
162
- | `amortizationSchedule(params)` | Full loan schedule with extra payments |
163
- | `prepaymentImpact(params)` | Compare original vs prepaid loan savings |
194
+ | Function | Formula | Description |
195
+ |----------|---------|-------------|
196
+ | `emi(principal, annualRate, months)` | EMI = P × r × (1+r)ⁿ / [(1+r)ⁿ − 1] | Equated Monthly Installment |
197
+ | `amortizationSchedule(params)` | Month-by-month principal/interest split | Full loan schedule with extra payments |
198
+ | `prepaymentImpact(params)` | Compares original vs prepaid loan | Interest saved and tenure reduction |
164
199
 
165
- **emi** returns: `{ emi, totalPayment, totalInterest }`
200
+ **EMI formula:** r = annualRate / 12. When annualRate = 0, EMI = principal / months.
201
+
202
+ Returns: `{ emi, totalPayment, totalInterest }`
166
203
 
167
204
  **amortizationSchedule params:**
168
205
  ```ts
@@ -186,29 +223,41 @@ Returns: `{ emi, schedule[], totalInterest, totalPaid, payoffMonth }`
186
223
  mode: "reduceTenure" | "reduceEmi"
187
224
  }
188
225
  ```
226
+ - `reduceTenure` — keeps EMI constant, pays off loan early
227
+ - `reduceEmi` — keeps tenure constant, recalculates lower EMI after each prepayment
228
+
189
229
  Returns: `{ original, new, savings: { interestSaved, monthsSaved } }`
190
230
 
191
231
  ### Risk Metrics
192
232
 
193
- | Function | Description |
194
- |----------|-------------|
195
- | `volatility(returns, periodsPerYear?)` | Annualized volatility (sample stdev * sqrt(periods)) |
196
- | `sharpe(returns, riskFreeRate?, periodsPerYear?)` | Sharpe Ratio |
197
- | `sortino(returns, riskFreeRate?, periodsPerYear?)` | Sortino Ratio (downside deviation only) |
198
- | `maxDrawdown(values)` | Maximum drawdown with peak/trough indices |
233
+ | Function | Formula | Description |
234
+ |----------|---------|-------------|
235
+ | `volatility(returns, periodsPerYear?)` | σ = s(returns) × √periodsPerYear | Annualized volatility (sample std dev) |
236
+ | `sharpe(returns, riskFreeRate?, periodsPerYear?)` | (R̄·T − Rf) / σ | Sharpe Ratio (Sharpe 1994) |
237
+ | `sortino(returns, riskFreeRate?, periodsPerYear?)` | (R̄·T − Rf) / DD | Sortino Ratio (Sortino & Price 1994) |
238
+ | `maxDrawdown(values)` | max((peak trough) / peak) | Maximum peak-to-trough decline |
239
+
240
+ **Volatility:** Uses sample standard deviation (N−1 denominator) × √periodsPerYear for annualization.
241
+
242
+ **Sharpe Ratio:** Arithmetic annualization of mean return. `SR = (meanReturn × periodsPerYear − riskFreeRateAnnual) / volatility`
243
+
244
+ **Sortino Ratio:** Downside deviation uses the full sample size N as denominator (per Sortino & Price 1994), not just the count of negative returns:
245
+ `DD = √(Σ min(rᵢ − MAR, 0)² / N) × √periodsPerYear`
246
+
247
+ Returns `Infinity` when no downside returns exist (zero downside risk).
199
248
 
200
249
  **Parameters:**
201
- - `returns` — array of periodic returns as decimals
202
- - `values` — array of portfolio values
203
- - `periodsPerYear` — defaults to 252 (daily trading)
250
+ - `returns` — array of periodic returns as decimals (e.g., daily: 0.01 = 1%)
251
+ - `values` — array of portfolio values (for maxDrawdown)
252
+ - `periodsPerYear` — defaults to 252 (daily trading days)
204
253
  - `riskFreeRate` — annual risk-free rate as decimal
205
254
 
206
255
  ### Portfolio Helpers
207
256
 
208
257
  | Function | Description |
209
258
  |----------|-------------|
210
- | `weightedReturn(returns, weights)` | Weighted average return |
211
- | `rebalance(targetWeights, currentValues)` | Calculate trades needed to rebalance |
259
+ | `weightedReturn(returns, weights)` | Weighted average return: Σ(rᵢ × wᵢ) |
260
+ | `rebalance(targetWeights, currentValues)` | Calculate trades to reach target allocation |
212
261
 
213
262
  **rebalance** returns: `{ trades, newValues, total }` — positive trade = buy, negative = sell
214
263
 
@@ -218,17 +267,24 @@ Returns: `{ original, new, savings: { interestSaved, monthsSaved } }`
218
267
  |----------|-------------|
219
268
  | `safeNum(value, fallback?)` | Returns fallback if value is NaN/Infinity/null/undefined |
220
269
 
221
- ## Conventions
270
+ ## Migration from v1.2.x
222
271
 
223
- - **Rates as decimals**: 12% = `0.12`, not `12`
224
- - **Step-up as percentage**: 10% step-up = `10`, not `0.10` (matches common Indian finance convention)
225
- - **Inflation as percentage** in SWP: `inflationRateAnnual: 6` means 6%
226
- - **Day count**: XNPV/XIRR default to ACT/365; pass `"ACT/360"` to override
227
- - **Period type**: `0` = end-of-period (ordinary annuity), `1` = beginning-of-period (annuity due)
272
+ **Breaking change in v1.3.0:** `stepUpPercentAnnual` and `inflationRateAnnual` (in SWP) now follow the same decimal convention as all other rates. Previously they expected whole-number percentages (e.g., `10` for 10%); now they expect decimals (e.g., `0.10` for 10%).
273
+
274
+ ```diff
275
+ - sipFutureValue({ monthlyInvestment: 25000, annualRate: 0.12, months: 180, stepUpPercentAnnual: 10 })
276
+ + sipFutureValue({ monthlyInvestment: 25000, annualRate: 0.12, months: 180, stepUpPercentAnnual: 0.10 })
277
+
278
+ - swpPlan({ initialCorpus: 10000000, monthlyWithdrawal: 50000, annualRate: 0.08, months: 300, inflationRateAnnual: 6 })
279
+ + swpPlan({ initialCorpus: 10000000, monthlyWithdrawal: 50000, annualRate: 0.08, months: 300, inflationRateAnnual: 0.06 })
280
+ ```
228
281
 
229
282
  ## Error Handling
230
283
 
231
- Functions return safe defaults (0, empty arrays) for invalid inputs instead of throwing, making them safe for use in reactive UIs. The `safeNum` utility guards against NaN/Infinity.
284
+ Most functions return safe defaults (0, empty arrays) for invalid inputs, making them safe for reactive UIs. Exceptions:
285
+
286
+ - `irr()` and `xirr()` throw an `Error` if cash flows don't contain at least one positive and one negative value
287
+ - `sortino()` returns `Infinity` when there are no downside returns
232
288
 
233
289
  ## License
234
290
 
package/dist/index.cjs CHANGED
@@ -208,7 +208,7 @@ function sipFutureValue(params) {
208
208
  let currentMonthly = monthlyInvestment;
209
209
  for (let m = 1; m <= months; m++) {
210
210
  if (stepUpPercentAnnual > 0 && m > 1 && (m - 1) % 12 === 0) {
211
- currentMonthly *= 1 + stepUpPercentAnnual / 100;
211
+ currentMonthly *= 1 + stepUpPercentAnnual;
212
212
  }
213
213
  totalInvested += currentMonthly;
214
214
  if (investmentAt === "begin") {
@@ -236,7 +236,7 @@ function swpPlan(params) {
236
236
  const schedule = [];
237
237
  for (let m = 1; m <= months; m++) {
238
238
  if (inflationRateAnnual > 0 && m > 1 && (m - 1) % 12 === 0) {
239
- currentWithdrawal *= 1 + inflationRateAnnual / 100;
239
+ currentWithdrawal *= 1 + inflationRateAnnual;
240
240
  }
241
241
  let interest;
242
242
  let withdrawal = currentWithdrawal;
@@ -293,7 +293,7 @@ function requiredMonthlyInvestmentForGoal(params) {
293
293
  }
294
294
  return {
295
295
  requiredMonthlyInvestment: (lo + hi) / 2,
296
- assumptions: `Rate: ${(annualRate * 100).toFixed(1)}% p.a., Duration: ${months} months${stepUpPercentAnnual ? `, Step-up: ${stepUpPercentAnnual}% annually` : ""}`
296
+ assumptions: `Rate: ${(annualRate * 100).toFixed(1)}% p.a., Duration: ${months} months${stepUpPercentAnnual ? `, Step-up: ${(stepUpPercentAnnual * 100).toFixed(1)}% annually` : ""}`
297
297
  };
298
298
  }
299
299
  function requiredLumpsumForGoal(goalAmountFuture, annualRate, years) {
@@ -459,14 +459,17 @@ function sharpe(returns, riskFreeRateAnnual = 0, periodsPerYear = 252) {
459
459
  return (annualReturn - riskFreeRateAnnual) / vol;
460
460
  }
461
461
  function sortino(returns, riskFreeRateAnnual = 0, periodsPerYear = 252) {
462
+ if (returns.length < 2) return 0;
462
463
  const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
463
464
  const rfPerPeriod = riskFreeRateAnnual / periodsPerYear;
464
- const downside = returns.filter((r) => r < rfPerPeriod);
465
- if (downside.length === 0) return Infinity;
466
- const downsideVariance = downside.reduce((s, r) => s + (r - rfPerPeriod) ** 2, 0) / downside.length;
467
- const downsideDev = Math.sqrt(downsideVariance) * Math.sqrt(periodsPerYear);
465
+ const downsideSquaredSum = returns.reduce((s, r) => {
466
+ const diff = r - rfPerPeriod;
467
+ return diff < 0 ? s + diff * diff : s;
468
+ }, 0);
469
+ if (downsideSquaredSum === 0) return Infinity;
470
+ const downsideDev = Math.sqrt(downsideSquaredSum / returns.length) * Math.sqrt(periodsPerYear);
468
471
  const annualReturn = mean * periodsPerYear;
469
- if (downsideDev === 0) return 0;
472
+ if (downsideDev === 0) return Infinity;
470
473
  return (annualReturn - riskFreeRateAnnual) / downsideDev;
471
474
  }
472
475
  function maxDrawdown(values) {
package/dist/index.mjs CHANGED
@@ -154,7 +154,7 @@ function sipFutureValue(params) {
154
154
  let currentMonthly = monthlyInvestment;
155
155
  for (let m = 1; m <= months; m++) {
156
156
  if (stepUpPercentAnnual > 0 && m > 1 && (m - 1) % 12 === 0) {
157
- currentMonthly *= 1 + stepUpPercentAnnual / 100;
157
+ currentMonthly *= 1 + stepUpPercentAnnual;
158
158
  }
159
159
  totalInvested += currentMonthly;
160
160
  if (investmentAt === "begin") {
@@ -182,7 +182,7 @@ function swpPlan(params) {
182
182
  const schedule = [];
183
183
  for (let m = 1; m <= months; m++) {
184
184
  if (inflationRateAnnual > 0 && m > 1 && (m - 1) % 12 === 0) {
185
- currentWithdrawal *= 1 + inflationRateAnnual / 100;
185
+ currentWithdrawal *= 1 + inflationRateAnnual;
186
186
  }
187
187
  let interest;
188
188
  let withdrawal = currentWithdrawal;
@@ -239,7 +239,7 @@ function requiredMonthlyInvestmentForGoal(params) {
239
239
  }
240
240
  return {
241
241
  requiredMonthlyInvestment: (lo + hi) / 2,
242
- assumptions: `Rate: ${(annualRate * 100).toFixed(1)}% p.a., Duration: ${months} months${stepUpPercentAnnual ? `, Step-up: ${stepUpPercentAnnual}% annually` : ""}`
242
+ assumptions: `Rate: ${(annualRate * 100).toFixed(1)}% p.a., Duration: ${months} months${stepUpPercentAnnual ? `, Step-up: ${(stepUpPercentAnnual * 100).toFixed(1)}% annually` : ""}`
243
243
  };
244
244
  }
245
245
  function requiredLumpsumForGoal(goalAmountFuture, annualRate, years) {
@@ -405,14 +405,17 @@ function sharpe(returns, riskFreeRateAnnual = 0, periodsPerYear = 252) {
405
405
  return (annualReturn - riskFreeRateAnnual) / vol;
406
406
  }
407
407
  function sortino(returns, riskFreeRateAnnual = 0, periodsPerYear = 252) {
408
+ if (returns.length < 2) return 0;
408
409
  const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
409
410
  const rfPerPeriod = riskFreeRateAnnual / periodsPerYear;
410
- const downside = returns.filter((r) => r < rfPerPeriod);
411
- if (downside.length === 0) return Infinity;
412
- const downsideVariance = downside.reduce((s, r) => s + (r - rfPerPeriod) ** 2, 0) / downside.length;
413
- const downsideDev = Math.sqrt(downsideVariance) * Math.sqrt(periodsPerYear);
411
+ const downsideSquaredSum = returns.reduce((s, r) => {
412
+ const diff = r - rfPerPeriod;
413
+ return diff < 0 ? s + diff * diff : s;
414
+ }, 0);
415
+ if (downsideSquaredSum === 0) return Infinity;
416
+ const downsideDev = Math.sqrt(downsideSquaredSum / returns.length) * Math.sqrt(periodsPerYear);
414
417
  const annualReturn = mean * periodsPerYear;
415
- if (downsideDev === 0) return 0;
418
+ if (downsideDev === 0) return Infinity;
416
419
  return (annualReturn - riskFreeRateAnnual) / downsideDev;
417
420
  }
418
421
  function maxDrawdown(values) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fundamental-js",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Comprehensive financial calculator library for investing, loans, and personal finance. Includes EMI, SIP, SWP, amortization, CAGR, IRR, NPV, risk metrics, and more.",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.mjs",