@actuarial-ts/core 0.1.0 → 0.3.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.
Files changed (51) hide show
  1. package/README.md +11 -4
  2. package/dist/canonical.d.ts +19 -0
  3. package/dist/canonical.d.ts.map +1 -0
  4. package/dist/canonical.js +118 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mack.d.ts.map +1 -1
  11. package/dist/mack.js +58 -18
  12. package/dist/mack.js.map +1 -1
  13. package/dist/odpBootstrap.d.ts +8 -2
  14. package/dist/odpBootstrap.d.ts.map +1 -1
  15. package/dist/odpBootstrap.js +11 -2
  16. package/dist/odpBootstrap.js.map +1 -1
  17. package/dist/types.d.ts +1 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/dist/types.js +4 -0
  20. package/dist/types.js.map +1 -1
  21. package/package.json +4 -2
  22. package/src/benktander.ts +90 -0
  23. package/src/berquist.ts +349 -0
  24. package/src/bf.ts +129 -0
  25. package/src/canonical.ts +122 -0
  26. package/src/capping.ts +295 -0
  27. package/src/caseOutstanding.ts +270 -0
  28. package/src/chainladder.ts +101 -0
  29. package/src/clark.ts +719 -0
  30. package/src/diagnostics.ts +435 -0
  31. package/src/discounting.ts +417 -0
  32. package/src/elrMethods.ts +257 -0
  33. package/src/factors.ts +147 -0
  34. package/src/fisherLange.ts +374 -0
  35. package/src/freqSev.ts +152 -0
  36. package/src/ilf.ts +567 -0
  37. package/src/index.ts +29 -0
  38. package/src/mack.ts +329 -0
  39. package/src/merzWuthrich.ts +147 -0
  40. package/src/munichChainLadder.ts +398 -0
  41. package/src/odpBootstrap.ts +337 -0
  42. package/src/onlevel.ts +155 -0
  43. package/src/salvageSubro.ts +205 -0
  44. package/src/stochastic.ts +151 -0
  45. package/src/tail.ts +156 -0
  46. package/src/trend.ts +150 -0
  47. package/src/triangle.ts +235 -0
  48. package/src/triangleAlgebra.ts +111 -0
  49. package/src/types.ts +357 -0
  50. package/src/ulae.ts +326 -0
  51. package/src/util.ts +68 -0
package/src/ulae.ts ADDED
@@ -0,0 +1,326 @@
1
+ import { ReservingError } from "./types.js";
2
+ import { isNum } from "./util.js";
3
+
4
+ /**
5
+ * ULAE reserving per Conger & Nolibos (2003), "Estimating ULAE Liabilities:
6
+ * Rediscovering and Expanding Kittel's Approach" (CAS Forum Fall 2003).
7
+ *
8
+ * The generalized framework splits ultimate ULAE into shares spent OPENING
9
+ * (u1), MAINTAINING (u2) and CLOSING (u3) claims, u1 + u2 + u3 = 1, and
10
+ * attaches each share to a calendar-period loss measure:
11
+ *
12
+ * - u1 <-> R: the ULTIMATE cost of claims REPORTED during the period,
13
+ * - u2 <-> P: the losses PAID during the period,
14
+ * - u3 <-> C: the ULTIMATE cost of claims CLOSED during the period.
15
+ *
16
+ * Core identity: M = W x B with loss basis B = u1 R + u2 P + u3 C, so the
17
+ * observed calendar-period ULAE ratio is W = M / B ("losses" always means
18
+ * losses + ALAE). The actuary reviews W by period, selects W*, and books one
19
+ * of the paper's three reserve forms (the Bornhuetter-Ferguson form is their
20
+ * recommended default).
21
+ *
22
+ * Special cases (see ULAE_WEIGHT_PRESETS): Kittel's refined method is
23
+ * u = (50%, 0, 50%) with R = reported and C = paid losses (its no-partial-
24
+ * payment / no-development assumptions); the classical paid-to-paid method
25
+ * adds the steady-state identity R = P = C, collapsing the basis to paid
26
+ * losses alone.
27
+ *
28
+ * Validation choices (documented per the phase plan):
29
+ * - weights must be finite, each within [0, 1], and sum to 1 (1e-6
30
+ * tolerance) -> BAD_WEIGHTS;
31
+ * - monetary amounts (M, R, P, C, L and their to-date counterparts) must be
32
+ * finite and non-negative -> BAD_LOSSES;
33
+ * - the selected ratio W* must be finite and non-negative -> BAD_RATIO;
34
+ * - a non-positive PER-PERIOD basis yields W = null with a warning (house
35
+ * null-safety: division by a non-positive denominator is "no factor",
36
+ * never an exception), but a reserve that cannot be formed at all (empty
37
+ * periods, missing measures for a positive weight, development form with
38
+ * a non-positive basis) throws NO_DATA.
39
+ */
40
+
41
+ export interface UlaeWeights {
42
+ /** Share of ultimate ULAE spent opening claims (attaches to R). */
43
+ u1: number;
44
+ /** Share spent maintaining claims (attaches to paid losses P). */
45
+ u2: number;
46
+ /** Share spent closing claims (attaches to C). */
47
+ u3: number;
48
+ }
49
+
50
+ /**
51
+ * How the calendar-period loss basis is formed:
52
+ * - "weighted": B = u1 R + u2 P + u3 C (the generalized framework);
53
+ * - "paid": B = P, the classical method's steady-state simplification
54
+ * (R = P = C for a book in steady state, pp. 98-99 of the paper).
55
+ */
56
+ export type UlaeBasisKind = "weighted" | "paid";
57
+
58
+ /**
59
+ * Weight presets from the paper. The GENERALIZED method is any caller-chosen
60
+ * triple (there is no handbook of u-values; the paper develops them by
61
+ * interviewing claims personnel), so it deliberately has no preset entry.
62
+ */
63
+ export const ULAE_WEIGHT_PRESETS = {
64
+ /**
65
+ * Kittel's refined method: 50% opening / 50% closing, with the caller
66
+ * supplying R = reported losses and C = paid losses per Kittel's
67
+ * assumptions (no partial payments or reopenings, no future development).
68
+ * W = M / (50% x (paid + reported)).
69
+ */
70
+ kittel: { weights: { u1: 0.5, u2: 0, u3: 0.5 }, basis: "weighted" } as const,
71
+ /**
72
+ * Classical paid-to-paid: the same 50/50 lifecycle assumption plus the
73
+ * steady-state identity R = P = C, which collapses the basis to paid
74
+ * losses: W = M / P. Reserve = W* x (IBNR + 50% x case reserves).
75
+ */
76
+ classicalPaidToPaid: { weights: { u1: 0.5, u2: 0, u3: 0.5 }, basis: "paid" } as const,
77
+ } as const;
78
+
79
+ /** One calendar period's inputs (all amounts for the period, not cumulative). */
80
+ export interface UlaePeriodInput {
81
+ label: string;
82
+ /** M: ULAE paid during the period. */
83
+ ulaePaid: number;
84
+ /** R: ultimate cost of claims reported during the period. */
85
+ reportedUltimate: number;
86
+ /** P: losses paid during the period. */
87
+ paid: number;
88
+ /** C: ultimate cost of claims closed during the period. */
89
+ closedUltimate: number;
90
+ }
91
+
92
+ export interface UlaeRatioRow {
93
+ label: string;
94
+ ulaePaid: number;
95
+ /** B = u1 R + u2 P + u3 C (or P under the "paid" basis). */
96
+ basis: number;
97
+ /** W = M / B; null when the basis is not positive. */
98
+ ratio: number | null;
99
+ }
100
+
101
+ export interface UlaeRatiosResult {
102
+ weights: UlaeWeights;
103
+ basis: UlaeBasisKind;
104
+ rows: UlaeRatioRow[];
105
+ totals: { ulaePaid: number; basis: number; ratio: number | null };
106
+ warnings: string[];
107
+ }
108
+
109
+ export type UlaeReserveForm = "expected" | "bornhuetterFerguson" | "development";
110
+
111
+ export interface UlaeReserveInput {
112
+ /** W*: the selected ratio of ultimate ULAE to the loss basis. */
113
+ selectedW: number;
114
+ /** L: independently estimated ultimate losses for the reserved group. */
115
+ ultimateLosses: number;
116
+ /** R(t): ultimate cost of claims reported as of the evaluation date. Required when u1 > 0. */
117
+ reportedToDate?: number;
118
+ /** P(t): losses paid as of the evaluation date. Required when u2 > 0. */
119
+ paidToDate?: number;
120
+ /** C(t): ultimate cost of claims closed as of the evaluation date. Required when u3 > 0. */
121
+ closedToDate?: number;
122
+ /** M(t): ULAE paid to date. Required for the expected and development forms. */
123
+ ulaePaidToDate?: number;
124
+ weights: UlaeWeights;
125
+ form: UlaeReserveForm;
126
+ }
127
+
128
+ export interface UlaeReserveResult {
129
+ form: UlaeReserveForm;
130
+ /** The indicated unpaid ULAE. */
131
+ unpaidUlae: number;
132
+ /** B(t) = u1 R(t) + u2 P(t) + u3 C(t); null when a needed measure was omitted. */
133
+ basisToDate: number | null;
134
+ /**
135
+ * The Bornhuetter-Ferguson form's split (opening = W* u1 (L - R(t)),
136
+ * maintaining = W* u2 (L - P(t)), closing = W* u3 (L - C(t))); null for
137
+ * the other forms.
138
+ */
139
+ components: { opening: number; maintaining: number; closing: number } | null;
140
+ warnings: string[];
141
+ }
142
+
143
+ const WEIGHT_SUM_TOLERANCE = 1e-6;
144
+
145
+ function validateWeights(weights: UlaeWeights): void {
146
+ const { u1, u2, u3 } = weights;
147
+ for (const [name, value] of [
148
+ ["u1", u1],
149
+ ["u2", u2],
150
+ ["u3", u3],
151
+ ] as const) {
152
+ if (!isNum(value) || value < 0 || value > 1) {
153
+ throw new ReservingError(
154
+ "BAD_WEIGHTS",
155
+ `ULAE weight ${name} must be a finite number within [0, 1], got ${value}`,
156
+ );
157
+ }
158
+ }
159
+ if (Math.abs(u1 + u2 + u3 - 1) > WEIGHT_SUM_TOLERANCE) {
160
+ throw new ReservingError(
161
+ "BAD_WEIGHTS",
162
+ `ULAE weights must sum to 1 (u1 + u2 + u3 = ${u1 + u2 + u3})`,
163
+ );
164
+ }
165
+ }
166
+
167
+ function requireNonNegative(value: number, name: string): void {
168
+ if (!isNum(value) || value < 0) {
169
+ throw new ReservingError("BAD_LOSSES", `${name} must be a finite, non-negative number, got ${value}`);
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Calendar-period ULAE ratios W = M / B (the paper's Exhibits B-F machinery).
175
+ * Pass opts.basis = "paid" for the classical paid-to-paid collapse.
176
+ */
177
+ export function ulaeRatios(
178
+ periods: UlaePeriodInput[],
179
+ weights: UlaeWeights,
180
+ opts: { basis?: UlaeBasisKind } = {},
181
+ ): UlaeRatiosResult {
182
+ validateWeights(weights);
183
+ if (periods.length === 0) {
184
+ throw new ReservingError("NO_DATA", "ULAE ratios need at least one calendar period");
185
+ }
186
+ const basisKind: UlaeBasisKind = opts.basis ?? "weighted";
187
+ const warnings: string[] = [];
188
+ const rows: UlaeRatioRow[] = [];
189
+ let totalM = 0;
190
+ let totalB = 0;
191
+ for (const period of periods) {
192
+ requireNonNegative(period.ulaePaid, `Period ${period.label}: ulaePaid`);
193
+ requireNonNegative(period.reportedUltimate, `Period ${period.label}: reportedUltimate`);
194
+ requireNonNegative(period.paid, `Period ${period.label}: paid`);
195
+ requireNonNegative(period.closedUltimate, `Period ${period.label}: closedUltimate`);
196
+ const basis =
197
+ basisKind === "paid"
198
+ ? period.paid
199
+ : weights.u1 * period.reportedUltimate +
200
+ weights.u2 * period.paid +
201
+ weights.u3 * period.closedUltimate;
202
+ const ratio = basis > 0 ? period.ulaePaid / basis : null;
203
+ if (ratio === null) {
204
+ warnings.push(
205
+ `Period ${period.label} has a non-positive loss basis; its ULAE ratio is not computable`,
206
+ );
207
+ }
208
+ rows.push({ label: period.label, ulaePaid: period.ulaePaid, basis, ratio });
209
+ totalM += period.ulaePaid;
210
+ totalB += basis;
211
+ }
212
+ return {
213
+ weights,
214
+ basis: basisKind,
215
+ rows,
216
+ totals: { ulaePaid: totalM, basis: totalB, ratio: totalB > 0 ? totalM / totalB : null },
217
+ warnings,
218
+ };
219
+ }
220
+
221
+ /**
222
+ * The paper's three reserve forms (pp. 113-115), analogous to expected-loss /
223
+ * Bornhuetter-Ferguson / development loss reserving:
224
+ *
225
+ * - "expected": unpaid = W* L - M(t)
226
+ * - "bornhuetterFerguson": unpaid = W* [u1 (L - R(t)) + u2 (L - P(t)) + u3 (L - C(t))]
227
+ * = W* (L - B(t)) -- the paper's recommended default
228
+ * - "development": unpaid = M(t) (L / B(t) - 1)
229
+ *
230
+ * With Kittel's weights (50/0/50) and C(t) = P(t) the B-F form reduces to
231
+ * W* x (IBNR + 50% x case reserves) - the Kittel identity.
232
+ */
233
+ export function ulaeReserve(input: UlaeReserveInput): UlaeReserveResult {
234
+ validateWeights(input.weights);
235
+ if (!isNum(input.selectedW) || input.selectedW < 0) {
236
+ throw new ReservingError(
237
+ "BAD_RATIO",
238
+ `Selected ULAE ratio W* must be a finite, non-negative number, got ${input.selectedW}`,
239
+ );
240
+ }
241
+ requireNonNegative(input.ultimateLosses, "ultimateLosses");
242
+ const { u1, u2, u3 } = input.weights;
243
+ const warnings: string[] = [];
244
+
245
+ // A measure is needed when its weight is positive (B-F and development
246
+ // forms); an omitted-but-unweighted measure is treated as 0 in B(t).
247
+ const measure = (
248
+ value: number | undefined,
249
+ weight: number,
250
+ name: string,
251
+ needed: boolean,
252
+ ): number => {
253
+ if (value === undefined) {
254
+ if (needed && weight > 0) {
255
+ throw new ReservingError(
256
+ "NO_DATA",
257
+ `${name} is required when its ULAE weight is positive (${input.form} form)`,
258
+ );
259
+ }
260
+ return 0;
261
+ }
262
+ requireNonNegative(value, name);
263
+ return value;
264
+ };
265
+ const needsBasis = input.form !== "expected";
266
+ const R = measure(input.reportedToDate, u1, "reportedToDate", needsBasis);
267
+ const P = measure(input.paidToDate, u2, "paidToDate", needsBasis);
268
+ const C = measure(input.closedToDate, u3, "closedToDate", needsBasis);
269
+ const haveBasis =
270
+ (u1 === 0 || input.reportedToDate !== undefined) &&
271
+ (u2 === 0 || input.paidToDate !== undefined) &&
272
+ (u3 === 0 || input.closedToDate !== undefined);
273
+ const basisToDate = haveBasis ? u1 * R + u2 * P + u3 * C : null;
274
+
275
+ const requireM = (): number => {
276
+ if (input.ulaePaidToDate === undefined) {
277
+ throw new ReservingError(
278
+ "NO_DATA",
279
+ `ulaePaidToDate is required for the ${input.form} ULAE reserve form`,
280
+ );
281
+ }
282
+ requireNonNegative(input.ulaePaidToDate, "ulaePaidToDate");
283
+ return input.ulaePaidToDate;
284
+ };
285
+
286
+ let unpaidUlae: number;
287
+ let components: UlaeReserveResult["components"] = null;
288
+ switch (input.form) {
289
+ case "expected": {
290
+ const M = requireM();
291
+ unpaidUlae = input.selectedW * input.ultimateLosses - M;
292
+ if (unpaidUlae < 0) {
293
+ warnings.push(
294
+ "ULAE paid to date exceeds W* x ultimate losses; the expected-form reserve is negative",
295
+ );
296
+ }
297
+ break;
298
+ }
299
+ case "bornhuetterFerguson": {
300
+ const L = input.ultimateLosses;
301
+ components = {
302
+ opening: input.selectedW * u1 * (L - R),
303
+ maintaining: input.selectedW * u2 * (L - P),
304
+ closing: input.selectedW * u3 * (L - C),
305
+ };
306
+ unpaidUlae = components.opening + components.maintaining + components.closing;
307
+ break;
308
+ }
309
+ case "development": {
310
+ const M = requireM();
311
+ if (basisToDate === null || basisToDate <= 0) {
312
+ throw new ReservingError(
313
+ "NO_DATA",
314
+ "The development ULAE reserve form needs a positive loss basis to date (B = u1 R + u2 P + u3 C)",
315
+ );
316
+ }
317
+ unpaidUlae = M * (input.ultimateLosses / basisToDate - 1);
318
+ warnings.push(
319
+ "The development form is overly responsive to random ULAE emergence (Conger-Nolibos); the Bornhuetter-Ferguson form is the paper's recommended default",
320
+ );
321
+ break;
322
+ }
323
+ }
324
+
325
+ return { form: input.form, unpaidUlae, basisToDate, components, warnings };
326
+ }
package/src/util.ts ADDED
@@ -0,0 +1,68 @@
1
+ /** Shared numeric helpers. All null-safe by construction. */
2
+
3
+ /** True when v is a usable finite number. */
4
+ export function isNum(v: number | null | undefined): v is number {
5
+ return typeof v === "number" && Number.isFinite(v);
6
+ }
7
+
8
+ /**
9
+ * Safe ratio: returns null when either side is missing or the denominator
10
+ * is missing, zero, or negative ("no factor", never an exception).
11
+ */
12
+ export function safeRatio(num: number | null, den: number | null): number | null {
13
+ if (!isNum(num) || !isNum(den) || den <= 0) return null;
14
+ return num / den;
15
+ }
16
+
17
+ /** Ordinary least squares of y on x. Returns null when fewer than 2 points. */
18
+ export function ols(
19
+ x: number[],
20
+ y: number[],
21
+ ): { intercept: number; slope: number; rSquared: number; n: number } | null {
22
+ const n = Math.min(x.length, y.length);
23
+ if (n < 2) return null;
24
+ let sx = 0;
25
+ let sy = 0;
26
+ for (let i = 0; i < n; i++) {
27
+ sx += x[i]!;
28
+ sy += y[i]!;
29
+ }
30
+ const mx = sx / n;
31
+ const my = sy / n;
32
+ let sxx = 0;
33
+ let sxy = 0;
34
+ let syy = 0;
35
+ for (let i = 0; i < n; i++) {
36
+ const dx = x[i]! - mx;
37
+ const dy = y[i]! - my;
38
+ sxx += dx * dx;
39
+ sxy += dx * dy;
40
+ syy += dy * dy;
41
+ }
42
+ if (sxx === 0) return null;
43
+ const slope = sxy / sxx;
44
+ const intercept = my - slope * mx;
45
+ // R^2 = 1 - SSE/SST; when SST is 0 the fit is a perfect horizontal line.
46
+ const rSquared = syy === 0 ? 1 : 1 - (syy - (sxy * sxy) / sxx) / syy;
47
+ return { intercept, slope, rSquared, n };
48
+ }
49
+
50
+ /** Index of the last non-null cell in a row; -1 when the row is empty. */
51
+ export function lastObservedIndex(row: (number | null)[]): number {
52
+ for (let j = row.length - 1; j >= 0; j--) {
53
+ if (isNum(row[j] ?? null)) return j;
54
+ }
55
+ return -1;
56
+ }
57
+
58
+ /**
59
+ * Index of the last cell observed in BOTH rows (paired-triangle diagonals);
60
+ * -1 when no cell is jointly observed. Interior holes are respected: the
61
+ * search walks left until both sides are numbers.
62
+ */
63
+ export function lastJointObservedIndex(a: (number | null)[], b: (number | null)[]): number {
64
+ for (let j = Math.min(a.length, b.length) - 1; j >= 0; j--) {
65
+ if (isNum(a[j] ?? null) && isNum(b[j] ?? null)) return j;
66
+ }
67
+ return -1;
68
+ }