@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
@@ -0,0 +1,435 @@
1
+ import type {
2
+ CalendarYearDiagnostic,
3
+ DiagnosticFinding,
4
+ DiagnosticsResult,
5
+ Triangle,
6
+ } from "./types.js";
7
+ import { isNum, ols, safeRatio } from "./util.js";
8
+ import { mackEstimators } from "./mack.js";
9
+
10
+ /**
11
+ * Data diagnostics an actuary uses to see when the data violates method
12
+ * assumptions:
13
+ * - paid-to-incurred ratios (case adequacy drift distorts incurred methods)
14
+ * - average case reserves (severity/adequacy shifts)
15
+ * - closure rates (settlement-rate shifts distort paid development)
16
+ * - calendar-year effect detection (Mack's 1994 diagonal rank test)
17
+ */
18
+
19
+ function ratioGrid(numTri: Triangle, denTri: Triangle): (number | null)[][] {
20
+ return numTri.origins.map((_, i) =>
21
+ numTri.ages.map((_, j) =>
22
+ safeRatio(numTri.values[i]?.[j] ?? null, denTri.values[i]?.[j] ?? null),
23
+ ),
24
+ );
25
+ }
26
+
27
+ /**
28
+ * Mack's calendar-year test: within each development column, factors above
29
+ * the column median are Large, below are Small (ties to the median drop out).
30
+ * Under the null of no diagonal effects, Large/Small mix randomly along each
31
+ * calendar diagonal; Z = sum over diagonals of min(#L, #S) has a known mean
32
+ * and variance. A total outside the 95% range flags calendar-year effects.
33
+ */
34
+ export function calendarYearTest(tri: Triangle): CalendarYearDiagnostic | null {
35
+ const nOrigins = tri.origins.length;
36
+ const nCols = tri.ages.length - 1;
37
+ if (nCols < 1 || nOrigins < 3) return null;
38
+
39
+ // Classify each factor cell as L / S relative to its column median.
40
+ type Mark = "L" | "S";
41
+ const marks: (Mark | null)[][] = tri.origins.map(() => new Array(nCols).fill(null));
42
+ for (let j = 0; j < nCols; j++) {
43
+ const entries: { i: number; f: number }[] = [];
44
+ for (let i = 0; i < nOrigins; i++) {
45
+ const f = safeRatio(tri.values[i]?.[j + 1] ?? null, tri.values[i]?.[j] ?? null);
46
+ if (isNum(f)) entries.push({ i, f });
47
+ }
48
+ if (entries.length < 2) continue;
49
+ const sorted = entries.map((e) => e.f).sort((a, b) => a - b);
50
+ const mid = sorted.length % 2 === 1
51
+ ? sorted[(sorted.length - 1) / 2]!
52
+ : (sorted[sorted.length / 2 - 1]! + sorted[sorted.length / 2]!) / 2;
53
+ for (const e of entries) {
54
+ if (e.f > mid) marks[e.i]![j] = "L";
55
+ else if (e.f < mid) marks[e.i]![j] = "S";
56
+ // equal to the median -> eliminated
57
+ }
58
+ }
59
+
60
+ // Group by calendar diagonal: factor cell (i, j) belongs to diagonal i + j + 1
61
+ // (the calendar period of the numerator age).
62
+ const byDiagonal = new Map<number, { L: number; S: number }>();
63
+ for (let i = 0; i < nOrigins; i++) {
64
+ for (let j = 0; j < nCols; j++) {
65
+ const mark = marks[i]![j];
66
+ if (!mark) continue;
67
+ const d = i + j + 1;
68
+ const bucket = byDiagonal.get(d) ?? { L: 0, S: 0 };
69
+ bucket[mark]++;
70
+ byDiagonal.set(d, bucket);
71
+ }
72
+ }
73
+
74
+ const binom = (n: number, k: number): number => {
75
+ if (k < 0 || k > n) return 0;
76
+ let result = 1;
77
+ for (let x = 1; x <= k; x++) result = (result * (n - x + 1)) / x;
78
+ return result;
79
+ };
80
+
81
+ const diagonals: CalendarYearDiagnostic["diagonals"] = [];
82
+ let totalZ = 0;
83
+ let expectedTotalZ = 0;
84
+ let varianceTotalZ = 0;
85
+ const sortedDiagonals = [...byDiagonal.entries()].sort((a, b) => a[0] - b[0]);
86
+ for (const [d, { L, S }] of sortedDiagonals) {
87
+ const n = L + S;
88
+ if (n < 1) continue;
89
+ const z = Math.min(L, S);
90
+ const m = Math.floor((n - 1) / 2);
91
+ const c = binom(n - 1, m);
92
+ const expectedZ = n / 2 - (c * n) / 2 ** n;
93
+ const varianceZ =
94
+ (n * (n - 1)) / 4 - (c * n * (n - 1)) / 2 ** n + expectedZ - expectedZ ** 2;
95
+ diagonals.push({ label: `diagonal ${d}`, countLarge: L, countSmall: S, z, expectedZ, varianceZ });
96
+ totalZ += z;
97
+ expectedTotalZ += expectedZ;
98
+ varianceTotalZ += varianceZ;
99
+ }
100
+ if (diagonals.length === 0) return null;
101
+
102
+ const half = 1.96 * Math.sqrt(Math.max(0, varianceTotalZ));
103
+ const confidenceInterval: [number, number] = [expectedTotalZ - half, expectedTotalZ + half];
104
+ return {
105
+ diagonals,
106
+ totalZ,
107
+ expectedTotalZ,
108
+ varianceTotalZ,
109
+ significant: totalZ < confidenceInterval[0] || totalZ > confidenceInterval[1],
110
+ confidenceInterval,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Median relative shift of the most recent origin periods against the prior
116
+ * ones, per development column, restricted to columns that are still
117
+ * developing (median value below the maturity cap). This is how an actuary
118
+ * reads a closure-rate exhibit: a step change in the recent rows at immature
119
+ * ages, not a smooth trend across saturated columns.
120
+ */
121
+ function recentVsPriorShift(
122
+ grid: (number | null)[][],
123
+ options: { recent?: number; maturityCap?: number } = {},
124
+ ): number | null {
125
+ const recentN = options.recent ?? 3;
126
+ const maturityCap = options.maturityCap ?? Infinity;
127
+ if (grid.length === 0) return null;
128
+ const nCols = grid[0]!.length;
129
+ const shifts: number[] = [];
130
+ for (let j = 0; j < nCols; j++) {
131
+ const values: number[] = [];
132
+ for (let i = 0; i < grid.length; i++) {
133
+ const v = grid[i]![j] ?? null;
134
+ if (isNum(v)) values.push(v);
135
+ }
136
+ if (values.length < recentN + 2) continue;
137
+ const sorted = [...values].sort((a, b) => a - b);
138
+ const median = sorted[Math.floor(sorted.length / 2)]!;
139
+ if (median >= maturityCap) continue; // column is saturated; no signal left
140
+ const recent = values.slice(-recentN);
141
+ const prior = values.slice(0, values.length - recentN);
142
+ const avg = (xs: number[]) => xs.reduce((a, b) => a + b, 0) / xs.length;
143
+ const priorAvg = avg(prior);
144
+ if (priorAvg === 0) continue;
145
+ shifts.push(avg(recent) / priorAvg - 1);
146
+ }
147
+ if (shifts.length === 0) return null;
148
+ shifts.sort((a, b) => a - b);
149
+ const mid = Math.floor(shifts.length / 2);
150
+ return shifts.length % 2 === 1 ? shifts[mid]! : (shifts[mid - 1]! + shifts[mid]!) / 2;
151
+ }
152
+
153
+ /**
154
+ * Median relative per-period trend of a metric across development columns,
155
+ * fitted down each column (same maturity across origin periods).
156
+ */
157
+ function medianColumnTrend(grid: (number | null)[][]): number | null {
158
+ if (grid.length === 0) return null;
159
+ const nCols = grid[0]!.length;
160
+ const trends: number[] = [];
161
+ for (let j = 0; j < nCols; j++) {
162
+ const xs: number[] = [];
163
+ const ys: number[] = [];
164
+ for (let i = 0; i < grid.length; i++) {
165
+ const v = grid[i]![j] ?? null;
166
+ if (isNum(v) && v > 0) {
167
+ xs.push(i);
168
+ ys.push(Math.log(v));
169
+ }
170
+ }
171
+ if (xs.length >= 3) {
172
+ const fit = ols(xs, ys);
173
+ if (fit) trends.push(Math.exp(fit.slope) - 1);
174
+ }
175
+ }
176
+ if (trends.length === 0) return null;
177
+ trends.sort((a, b) => a - b);
178
+ const mid = Math.floor(trends.length / 2);
179
+ return trends.length % 2 === 1 ? trends[mid]! : (trends[mid - 1]! + trends[mid]!) / 2;
180
+ }
181
+
182
+ export interface DiagnosticsInput {
183
+ paid: Triangle;
184
+ incurred: Triangle;
185
+ openCounts: Triangle;
186
+ reportedCounts: Triangle;
187
+ closedCounts: Triangle;
188
+ }
189
+
190
+ export function runDiagnostics(input: DiagnosticsInput): DiagnosticsResult {
191
+ const { paid, incurred, openCounts, reportedCounts, closedCounts } = input;
192
+
193
+ const paidToIncurredRatios = ratioGrid(paid, incurred);
194
+ const caseGrid: (number | null)[][] = paid.origins.map((_, i) =>
195
+ paid.ages.map((_, j) => {
196
+ const inc = incurred.values[i]?.[j] ?? null;
197
+ const pd = paid.values[i]?.[j] ?? null;
198
+ const open = openCounts.values[i]?.[j] ?? null;
199
+ const caseAmt = isNum(inc) && isNum(pd) ? inc - pd : null;
200
+ return safeRatio(caseAmt, open);
201
+ }),
202
+ );
203
+ const closureRates = ratioGrid(closedCounts, reportedCounts);
204
+ const cyTest = calendarYearTest(paid);
205
+
206
+ const findings: DiagnosticFinding[] = [];
207
+ const closureShift = recentVsPriorShift(closureRates, { recent: 3, maturityCap: 0.95 });
208
+ if (closureShift !== null && Math.abs(closureShift) > 0.05) {
209
+ findings.push({
210
+ severity: "warning",
211
+ code: "SETTLEMENT_RATE_SHIFT",
212
+ message: `Claim settlement is ${closureShift > 0 ? "speeding up" : "slowing down"}: at still-developing maturities, closure rates for the most recent origin periods run ${(Math.abs(closureShift) * 100).toFixed(0)}% ${closureShift > 0 ? "above" : "below"} the prior periods. Paid development factors are distorted; weigh the Berquist-Sherman settlement-rate adjustment.`,
213
+ });
214
+ }
215
+ const caseTrend = medianColumnTrend(caseGrid);
216
+ if (caseTrend !== null && Math.abs(caseTrend) > 0.08) {
217
+ findings.push({
218
+ severity: "warning",
219
+ code: "CASE_ADEQUACY_SHIFT",
220
+ message: `Average case reserves at the same maturity are trending ${caseTrend > 0 ? "up" : "down"} ${(caseTrend * 100).toFixed(1)}% per period, which suggests a change in case reserve adequacy (or severity). Incurred development is distorted; consider the Berquist-Sherman case-reserve adjustment and compare against a severity trend expectation.`,
221
+ });
222
+ }
223
+ const p2iTrend = medianColumnTrend(paidToIncurredRatios);
224
+ if (p2iTrend !== null && Math.abs(p2iTrend) > 0.03) {
225
+ findings.push({
226
+ severity: "info",
227
+ code: "PAID_TO_INCURRED_SHIFT",
228
+ message: `Paid-to-incurred ratios at the same maturity are drifting ${p2iTrend > 0 ? "up" : "down"} (median ${(p2iTrend * 100).toFixed(1)}% per period). Paid and incurred projections are likely to diverge; investigate before relying on either alone.`,
229
+ });
230
+ }
231
+ if (cyTest?.significant) {
232
+ findings.push({
233
+ severity: "warning",
234
+ code: "CALENDAR_YEAR_EFFECT",
235
+ message: `Mack's calendar-year test rejects the null of no diagonal effects (Z = ${cyTest.totalZ.toFixed(1)} vs expected ${cyTest.expectedTotalZ.toFixed(1)}, 95% range ${cyTest.confidenceInterval[0].toFixed(1)}-${cyTest.confidenceInterval[1].toFixed(1)}). Calendar-period influences (inflation spikes, process changes, reserve reviews) are present; development-based methods assume these away.`,
236
+ });
237
+ }
238
+ if (findings.length === 0) {
239
+ findings.push({
240
+ severity: "info",
241
+ code: "NO_MATERIAL_DISTORTIONS",
242
+ message:
243
+ "No material settlement-rate, case-adequacy, or calendar-year distortions detected. Standard development assumptions look reasonable for this data.",
244
+ });
245
+ }
246
+
247
+ return {
248
+ paidToIncurredRatios,
249
+ averageCaseReserves: caseGrid,
250
+ closureRates,
251
+ calendarYearTest: cyTest,
252
+ findings,
253
+ };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Mack (1994) Appendix G: test for correlations between subsequent
258
+ // development factors.
259
+
260
+ export interface FactorCorrelationColumn {
261
+ /** Development year k in Mack's 1-indexed notation (subsequent factors F_k vs preceding F_{k-1}). */
262
+ k: number;
263
+ /** Number of (preceding, subsequent) factor pairs used. */
264
+ pairs: number;
265
+ /** Spearman rank correlation T_k. */
266
+ statistic: number;
267
+ }
268
+
269
+ export interface FactorCorrelationResult {
270
+ columns: FactorCorrelationColumn[];
271
+ /** T: the (pairs-1)-weighted average of the T_k. E[T] = 0 under the null. */
272
+ statistic: number;
273
+ /** Var(T) = 1 / sum of weights. */
274
+ variance: number;
275
+ /** Half-width of the 50% interval: 0.67 x sqrt(Var(T)). */
276
+ bound50: number;
277
+ /** True when |T| exceeds the 50% bound (be reluctant with chain ladder). */
278
+ correlated: boolean;
279
+ warnings: string[];
280
+ }
281
+
282
+ /** Average ranks (ties averaged), 1-based. */
283
+ function averageRanks(values: number[]): number[] {
284
+ const order = values
285
+ .map((v, idx) => ({ v, idx }))
286
+ .sort((a, b) => a.v - b.v);
287
+ const ranks = new Array<number>(values.length).fill(0);
288
+ let pos = 0;
289
+ while (pos < order.length) {
290
+ let end = pos;
291
+ while (end + 1 < order.length && order[end + 1]!.v === order[pos]!.v) end++;
292
+ const avg = (pos + end) / 2 + 1;
293
+ for (let x = pos; x <= end; x++) ranks[order[x]!.idx] = avg;
294
+ pos = end + 1;
295
+ }
296
+ return ranks;
297
+ }
298
+
299
+ /**
300
+ * Mack's Spearman test for correlation between subsequent development
301
+ * factors (Mack 1994, CAS Forum Spring 1994, Appendix G).
302
+ *
303
+ * For each development year k = 2..I-2: rank the subsequent factors F_k over
304
+ * the rows where the NEXT factor exists, rank the preceding factors F_{k-1}
305
+ * over those same rows (re-ranked after dropping the deeper rows), and take
306
+ * T_k = 1 - 6 sum(d^2) / (n(n^2-1)). Combine with weights (pairs - 1) —
307
+ * inverse to Var(T_k) = 1/(n_k - 1). Under the null E[T] = 0 and
308
+ * Var(T) = 1/sum(weights); the DELIBERATELY tight 50% interval
309
+ * (|T| <= 0.67 sqrt(Var)) screens for correlation in a substantial part of
310
+ * the triangle rather than formally testing at 5%.
311
+ *
312
+ * Returns null when no column pair has at least two factor pairs.
313
+ */
314
+ export function factorCorrelationTest(tri: Triangle): FactorCorrelationResult | null {
315
+ const n = tri.origins.length;
316
+ const nCols = tri.ages.length - 1;
317
+ const warnings: string[] = [];
318
+
319
+ // F[i][c] = C[i][c+1] / C[i][c] (0-indexed column c holds Mack's F_{c+1}).
320
+ const F: (number | null)[][] = tri.origins.map((_, i) =>
321
+ Array.from({ length: nCols }, (_, c) =>
322
+ safeRatio(tri.values[i]?.[c + 1] ?? null, tri.values[i]?.[c] ?? null),
323
+ ),
324
+ );
325
+
326
+ const columns: FactorCorrelationColumn[] = [];
327
+ for (let k = 2; k <= Math.min(n - 2, nCols); k++) {
328
+ const cs = k - 1; // 0-indexed subsequent column (F_k)
329
+ const cp = k - 2; // 0-indexed preceding column (F_{k-1})
330
+ const sub: number[] = [];
331
+ const pre: number[] = [];
332
+ for (let i = 0; i < n; i++) {
333
+ const a = F[i]![cs] ?? null;
334
+ const b = F[i]![cp] ?? null;
335
+ if (isNum(a) && isNum(b)) {
336
+ sub.push(a);
337
+ pre.push(b);
338
+ }
339
+ }
340
+ const m = sub.length;
341
+ if (m < 2) continue;
342
+ const hasTies =
343
+ new Set(sub).size !== sub.length || new Set(pre).size !== pre.length;
344
+ if (hasTies) {
345
+ warnings.push(
346
+ `Development year ${k}: tied factors; the Spearman statistic is approximate under ties`,
347
+ );
348
+ }
349
+ const r = averageRanks(sub);
350
+ const s = averageRanks(pre);
351
+ let d2 = 0;
352
+ for (let x = 0; x < m; x++) d2 += (r[x]! - s[x]!) ** 2;
353
+ const Tk = 1 - (6 * d2) / (m * (m * m - 1));
354
+ columns.push({ k, pairs: m, statistic: Tk });
355
+ }
356
+
357
+ if (columns.length === 0) return null;
358
+ const weightSum = columns.reduce((a, c) => a + (c.pairs - 1), 0);
359
+ if (weightSum <= 0) return null;
360
+ const statistic = columns.reduce((a, c) => a + (c.pairs - 1) * c.statistic, 0) / weightSum;
361
+ const variance = 1 / weightSum;
362
+ const bound50 = 0.67 * Math.sqrt(variance);
363
+ return {
364
+ columns,
365
+ statistic,
366
+ variance,
367
+ bound50,
368
+ correlated: Math.abs(statistic) > bound50,
369
+ warnings,
370
+ };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Mack residuals: standardized development residuals for plotting and for
375
+ // eyeballing origin / development / calendar structure.
376
+
377
+ export interface MackResidualCell {
378
+ origin: string;
379
+ fromAge: number;
380
+ toAge: number;
381
+ /** 1-based calendar diagonal of the factor's numerator cell. */
382
+ calendar: number;
383
+ factor: number;
384
+ /** (F - f_k) sqrt(C_ik) / sigma_k; null where sigma^2 is not data-estimable or zero. */
385
+ residual: number | null;
386
+ }
387
+
388
+ export interface MackResidualsResult {
389
+ cells: MackResidualCell[];
390
+ warnings: string[];
391
+ }
392
+
393
+ /**
394
+ * Standardized Mack residuals r_ik = (F_ik - f_k) sqrt(C_ik) / sigma_k
395
+ * around the volume-weighted factors, with sigma^2_k estimated from the
396
+ * data (Mack 1994). Columns whose sigma^2 cannot be estimated from at least
397
+ * two factors — or where the factors show no dispersion at all — yield null
398
+ * residuals with a warning; extrapolated sigma^2 is deliberately NOT used
399
+ * here (a residual against an extrapolated scale is not a diagnostic).
400
+ *
401
+ * Structural property: within each column, sum_i residual_ik x sqrt(C_ik)
402
+ * = 0 exactly, because f_k is the volume-weighted average.
403
+ */
404
+ export function mackResiduals(tri: Triangle): MackResidualsResult {
405
+ const warnings: string[] = [];
406
+ const { f, sigma2 } = mackEstimators(tri);
407
+ const cells: MackResidualCell[] = [];
408
+ const flaggedColumns = new Set<number>();
409
+
410
+ for (let c = 0; c < tri.ages.length - 1; c++) {
411
+ const s2 = sigma2[c] ?? null;
412
+ const usable = isNum(s2) && s2 > 0;
413
+ if (!usable && !flaggedColumns.has(c)) {
414
+ flaggedColumns.add(c);
415
+ warnings.push(
416
+ `Column ${tri.ages[c]}-${tri.ages[c + 1]}: sigma^2 is ${s2 === null ? "not estimable from the data" : "zero (no dispersion)"}; residuals are null there`,
417
+ );
418
+ }
419
+ for (let i = 0; i < tri.origins.length; i++) {
420
+ const c0 = tri.values[i]?.[c] ?? null;
421
+ const c1 = tri.values[i]?.[c + 1] ?? null;
422
+ if (!isNum(c0) || !isNum(c1) || c0 <= 0) continue;
423
+ const factor = c1 / c0;
424
+ cells.push({
425
+ origin: tri.origins[i]!,
426
+ fromAge: tri.ages[c]!,
427
+ toAge: tri.ages[c + 1]!,
428
+ calendar: i + c + 1,
429
+ factor,
430
+ residual: usable ? ((factor - f[c]!) * Math.sqrt(c0)) / Math.sqrt(s2!) : null,
431
+ });
432
+ }
433
+ }
434
+ return { cells, warnings };
435
+ }