@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,337 @@
1
+ import type { Triangle } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum, lastObservedIndex } from "./util.js";
4
+ import { cumulativeToIncremental } from "./triangleAlgebra.js";
5
+ import {
6
+ createRng,
7
+ summarizeSample,
8
+ type StochasticResult,
9
+ } from "./stochastic.js";
10
+
11
+ /**
12
+ * Over-dispersed Poisson (ODP) bootstrap of the chain ladder.
13
+ *
14
+ * Ground truth (England & Verrall 1999/2002; Shapland, CAS Monograph No. 4):
15
+ * - The cross-classified ODP GLM's fitted values reproduce the all-year
16
+ * volume-weighted chain ladder EXACTLY. In practice the fitted past
17
+ * incrementals come from the backwards recursion: anchor each row's
18
+ * latest observed cumulative, divide back through the volume-weighted
19
+ * factors, and difference.
20
+ * - Unscaled Pearson residuals r = (q - m) / sqrt(m) on incrementals; the
21
+ * scale parameter is phi = sum(r^2) / (n - p) with p = 2I - 1 parameters for
22
+ * an I-origin triangle and n = the incrementals that CONTRIBUTE a residual.
23
+ * On a well-behaved triangle that is every observed incremental, but a
24
+ * non-positive fitted mean leaves the residual undefined: such a cell is
25
+ * absent from the numerator, so counting it in the denominator would divide
26
+ * the sum of squares by degrees of freedom it never earned.
27
+ * - Resampling: draw residuals with replacement onto the fitted past
28
+ * (q* = m + r sqrt(m)), cumulate, refit volume-weighted factors, project
29
+ * each future incremental, and add process variance by sampling
30
+ * Gamma(mean m_f, variance phi m_f). Residuals are inflated by
31
+ * sqrt(n / (n - p)) first (the standard small-sample bias adjustment) —
32
+ * controllable via options.
33
+ * - Structural zero residuals (cells the fit reproduces exactly by
34
+ * construction, e.g. the corners) are excluded from the resampling pool.
35
+ *
36
+ * The GLM-mean == chain-ladder identity is the method's own validation
37
+ * hook: `odpFit(tri).reserveByOrigin` must tie to the volume-weighted
38
+ * chain ladder to floating-point precision, and the test suite pins it.
39
+ */
40
+
41
+ export interface OdpFit {
42
+ /** Volume-weighted all-year factors, one per development interval. */
43
+ factors: number[];
44
+ /** Fitted past incrementals m_ij (null where unobserved). */
45
+ fittedIncrementals: (number | null)[][];
46
+ /** Unscaled Pearson residuals (null where unobserved or structurally zero). */
47
+ residuals: (number | null)[][];
48
+ /** Future incremental means per cell (null where already observed). */
49
+ futureMeans: (number | null)[][];
50
+ /** Expected unpaid per origin (== volume-weighted chain ladder reserve). */
51
+ reserveByOrigin: { origin: string; reserve: number }[];
52
+ phi: number;
53
+ /** Incrementals contributing a residual — the dispersion's numerator count. */
54
+ n: number;
55
+ /** Fitted parameters, 2I - 1 for an I-origin triangle. */
56
+ p: number;
57
+ /** Residuals actually in the resampling pool (structural zeros excluded). */
58
+ poolSize: number;
59
+ warnings: string[];
60
+ }
61
+
62
+ /** Volume-weighted all-year factors (the ODP GLM's implied development). */
63
+ function volumeWeightedFactors(tri: Triangle): number[] {
64
+ const K = tri.ages.length;
65
+ const out: number[] = [];
66
+ for (let j = 0; j < K - 1; j++) {
67
+ let num = 0;
68
+ let den = 0;
69
+ for (let i = 0; i < tri.origins.length; i++) {
70
+ const c0 = tri.values[i]![j] ?? null;
71
+ const c1 = tri.values[i]![j + 1] ?? null;
72
+ if (isNum(c0) && isNum(c1) && c0 > 0) {
73
+ num += c1;
74
+ den += c0;
75
+ }
76
+ }
77
+ if (den <= 0) {
78
+ throw new ReservingError(
79
+ "NO_FACTOR",
80
+ `Development column ${tri.ages[j]}-${tri.ages[j + 1]} has no usable factors for the ODP fit`,
81
+ );
82
+ }
83
+ out.push(num / den);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** Fits the ODP cross-classified model via the chain-ladder equivalence. */
89
+ export function odpFit(tri: Triangle): OdpFit {
90
+ const warnings: string[] = [];
91
+ const I = tri.origins.length;
92
+ const K = tri.ages.length;
93
+ if (I < 3 || K < 3) {
94
+ throw new ReservingError("TOO_SMALL", "The ODP bootstrap needs at least a 3x3 triangle");
95
+ }
96
+ const factors = volumeWeightedFactors(tri);
97
+ const incr = cumulativeToIncremental(tri);
98
+
99
+ // Backwards recursion for fitted past cumulatives, anchored at each row's
100
+ // latest observed diagonal.
101
+ const fittedCum: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
102
+ const latestIdx: number[] = [];
103
+ for (let i = 0; i < I; i++) {
104
+ const d = lastObservedIndex(tri.values[i]!);
105
+ latestIdx.push(d);
106
+ if (d < 0) {
107
+ warnings.push(`Origin ${tri.origins[i]} has no observed cells; excluded from the fit`);
108
+ continue;
109
+ }
110
+ const anchor = tri.values[i]![d]!;
111
+ fittedCum[i]![d] = anchor;
112
+ for (let j = d - 1; j >= 0; j--) {
113
+ fittedCum[i]![j] = fittedCum[i]![j + 1]! / factors[j]!;
114
+ }
115
+ }
116
+
117
+ // Fitted past incrementals.
118
+ const fittedIncrementals: (number | null)[][] = fittedCum.map((row, i) => {
119
+ const d = latestIdx[i]!;
120
+ return row.map((v, j) => {
121
+ if (j > d || !isNum(v)) return null;
122
+ if (j === 0) return v;
123
+ return v - row[j - 1]!;
124
+ });
125
+ });
126
+
127
+ // Pearson residuals; structural zeros (|q - m| ~ 0 at machine precision
128
+ // where the fit reproduces the cell by construction) leave the pool.
129
+ const residuals: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
130
+ let n = 0;
131
+ let sumSq = 0;
132
+ let pool = 0;
133
+ let negativeFitted = 0;
134
+ for (let i = 0; i < I; i++) {
135
+ for (let j = 0; j <= latestIdx[i]!; j++) {
136
+ const q = incr.values[i]![j] ?? null;
137
+ const m = fittedIncrementals[i]![j] ?? null;
138
+ if (!isNum(q) || !isNum(m)) continue;
139
+ if (m <= 0) {
140
+ // The Pearson residual (q - m)/sqrt(m) is undefined for a non-positive
141
+ // fitted mean, so this cell contributes nothing to `sumSq` — and must
142
+ // therefore not count toward the degrees of freedom that divide it.
143
+ // Counting it would inflate `n - p`, understating phi and with it the
144
+ // process variance, which is the wrong direction for a reserve range.
145
+ negativeFitted++;
146
+ continue;
147
+ }
148
+ n++;
149
+ const r = (q - m) / Math.sqrt(m);
150
+ sumSq += r * r;
151
+ if (Math.abs(r) > 1e-10) {
152
+ residuals[i]![j] = r;
153
+ pool++;
154
+ } else {
155
+ residuals[i]![j] = 0; // structural zero: reported, not resampled
156
+ }
157
+ }
158
+ }
159
+ if (negativeFitted > 0) {
160
+ warnings.push(
161
+ `${negativeFitted} fitted incremental(s) are non-positive; their residuals are undefined and excluded (see Shapland on negative incrementals)`,
162
+ );
163
+ }
164
+ const p = 2 * I - 1;
165
+ if (n <= p) {
166
+ throw new ReservingError(
167
+ "TOO_SMALL",
168
+ `The ODP fit has ${n} usable residual(s) for ${p} parameters; no degrees of ` +
169
+ `freedom remain to estimate dispersion` +
170
+ (negativeFitted > 0
171
+ ? ` (${negativeFitted} cell(s) were excluded for a non-positive fitted mean)`
172
+ : ""),
173
+ );
174
+ }
175
+ const phi = sumSq / (n - p);
176
+
177
+ // Future incremental means from the fitted projection.
178
+ const futureMeans: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
179
+ const reserveByOrigin: { origin: string; reserve: number }[] = [];
180
+ for (let i = 0; i < I; i++) {
181
+ const d = latestIdx[i]!;
182
+ if (d < 0) continue;
183
+ let cum = tri.values[i]![d]!;
184
+ let reserve = 0;
185
+ for (let j = d + 1; j < K; j++) {
186
+ const next = cum * factors[j - 1]!;
187
+ const m = next - cum;
188
+ futureMeans[i]![j] = m;
189
+ reserve += m;
190
+ cum = next;
191
+ }
192
+ reserveByOrigin.push({ origin: tri.origins[i]!, reserve });
193
+ }
194
+
195
+ return {
196
+ factors,
197
+ fittedIncrementals,
198
+ residuals,
199
+ futureMeans,
200
+ reserveByOrigin,
201
+ phi,
202
+ n,
203
+ p,
204
+ poolSize: pool,
205
+ warnings,
206
+ };
207
+ }
208
+
209
+ export interface OdpBootstrapOptions {
210
+ nSims: number;
211
+ seed: number;
212
+ /** Inflate residuals by sqrt(n/(n-p)) before resampling (default true). */
213
+ biasAdjust?: boolean;
214
+ /** Add ODP process variance via gamma sampling (default true). */
215
+ processVariance?: boolean;
216
+ }
217
+
218
+ export interface OdpBootstrapResult extends StochasticResult {
219
+ method: "odpBootstrap";
220
+ fit: OdpFit;
221
+ }
222
+
223
+ /** The ODP bootstrap. Same seed + same triangle = same result, bit for bit. */
224
+ export function runOdpBootstrap(tri: Triangle, options: OdpBootstrapOptions): OdpBootstrapResult {
225
+ const { nSims, seed } = options;
226
+ if (!Number.isInteger(nSims) || nSims < 100) {
227
+ throw new ReservingError("TOO_SMALL", "The bootstrap needs an integer nSims >= 100");
228
+ }
229
+ const biasAdjust = options.biasAdjust ?? true;
230
+ const processVariance = options.processVariance ?? true;
231
+
232
+ const fit = odpFit(tri);
233
+ const warnings = [...fit.warnings];
234
+ const I = tri.origins.length;
235
+ const K = tri.ages.length;
236
+ const rng = createRng(seed);
237
+
238
+ // Residual pool (bias-adjusted), flattened.
239
+ const inflate = biasAdjust ? Math.sqrt(fit.n / (fit.n - fit.p)) : 1;
240
+ const pool: number[] = [];
241
+ for (const row of fit.residuals) {
242
+ for (const r of row) {
243
+ if (isNum(r) && Math.abs(r) > 1e-10) pool.push(r * inflate);
244
+ }
245
+ }
246
+ if (pool.length < 10) {
247
+ throw new ReservingError("TOO_SMALL", "Fewer than 10 usable residuals; bootstrap unreliable");
248
+ }
249
+
250
+ const latestIdx = tri.values.map((row) => lastObservedIndex(row));
251
+ let negativeFutureMeans = 0;
252
+
253
+ const totals: number[] = new Array(nSims).fill(0);
254
+ const perOrigin: number[][] = tri.origins.map(() => new Array(nSims).fill(0));
255
+
256
+ for (let b = 0; b < nSims; b++) {
257
+ // 1. Pseudo past incrementals -> pseudo cumulative triangle.
258
+ const pseudoCum: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
259
+ for (let i = 0; i < I; i++) {
260
+ let running = 0;
261
+ for (let j = 0; j <= latestIdx[i]!; j++) {
262
+ const m = fit.fittedIncrementals[i]![j];
263
+ if (!isNum(m) || m <= 0) {
264
+ // Cells without a usable fitted value keep their mean contribution.
265
+ running += isNum(m) ? m : 0;
266
+ } else {
267
+ const r = pool[Math.floor(rng.next() * pool.length)]!;
268
+ running += m + r * Math.sqrt(m);
269
+ }
270
+ pseudoCum[i]![j] = running;
271
+ }
272
+ }
273
+ const pseudo: Triangle = {
274
+ kind: tri.kind,
275
+ origins: tri.origins,
276
+ ages: tri.ages,
277
+ values: pseudoCum,
278
+ };
279
+
280
+ // 2. Refit and project; 3. process variance on each future incremental.
281
+ let factors: number[];
282
+ try {
283
+ factors = volumeWeightedFactors(pseudo);
284
+ } catch {
285
+ // A pathological resample (non-positive column volume) is discarded by
286
+ // reusing the fitted factors; counted and warned once at the end.
287
+ factors = fit.factors;
288
+ negativeFutureMeans++;
289
+ }
290
+ for (let i = 0; i < I; i++) {
291
+ const d = latestIdx[i]!;
292
+ if (d < 0) continue;
293
+ let cum = pseudoCum[i]![d]!;
294
+ let reserve = 0;
295
+ for (let j = d + 1; j < K; j++) {
296
+ const next = cum * factors[j - 1]!;
297
+ let m = next - cum;
298
+ if (processVariance) {
299
+ if (m > 0) {
300
+ // Gamma with mean m, variance phi m.
301
+ m = rng.gamma(m / fit.phi) * fit.phi;
302
+ } else if (m < 0) {
303
+ negativeFutureMeans++;
304
+ }
305
+ }
306
+ reserve += m;
307
+ // The projection chain advances on the mean path; process noise is
308
+ // per-cell around the mean (England & Verrall 2002, Appendix 3).
309
+ cum = next;
310
+ }
311
+ perOrigin[i]![b] = reserve;
312
+ totals[b]! += reserve;
313
+ }
314
+ }
315
+ if (negativeFutureMeans > 0) {
316
+ warnings.push(
317
+ `${negativeFutureMeans} simulated future incremental(s)/resample(s) had non-positive means; added deterministically without process variance (documented simplification for downward development)`,
318
+ );
319
+ }
320
+
321
+ const byOrigin = tri.origins
322
+ .map((origin, i) => ({ origin, samples: perOrigin[i]! }))
323
+ .filter((e) => latestIdx[tri.origins.indexOf(e.origin)]! >= 0 && e.samples.some((v) => v !== 0))
324
+ .map((e) => ({ origin: e.origin, summary: summarizeSample(e.samples) }));
325
+
326
+ return {
327
+ method: "odpBootstrap",
328
+ quantity: "unpaid",
329
+ seed,
330
+ nSims,
331
+ total: summarizeSample(totals),
332
+ byOrigin,
333
+ totalSamples: [...totals].sort((a, b) => a - b),
334
+ fit,
335
+ warnings,
336
+ };
337
+ }
package/src/onlevel.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { ReservingError } from "./types.js";
2
+ import { isNum } from "./util.js";
3
+
4
+ /**
5
+ * Parallelogram-method premium on-leveling (Werner & Modlin ch. 5).
6
+ *
7
+ * Assumptions, stated plainly: policies carry an ANNUAL term, are written
8
+ * uniformly through time, and premium earns evenly over the policy term. A
9
+ * rate change applies to policies WRITTEN on or after its effective date.
10
+ * Under those assumptions the share of a calendar period's EARNED premium
11
+ * sitting at each historical rate level is a piece of parallelogram
12
+ * geometry, computed here exactly (piecewise-linear integration, no grids).
13
+ *
14
+ * The on-level factor for a period = current cumulative rate level divided
15
+ * by the average rate level earned in that period.
16
+ */
17
+
18
+ export interface RateChange {
19
+ /** ISO date the change takes effect (applies to policies written on/after it). */
20
+ effectiveDate: string;
21
+ /** Rate change, e.g. 0.05 = +5%. */
22
+ change: number;
23
+ }
24
+
25
+ export interface OnLevelRow {
26
+ origin: string;
27
+ /** Average relative rate level earned in the period (1 = initial level). */
28
+ averageRateLevel: number;
29
+ /** currentLevel / averageRateLevel. */
30
+ onLevelFactor: number;
31
+ }
32
+
33
+ export interface OnLevelResult {
34
+ rows: OnLevelRow[];
35
+ /** Cumulative rate level after all changes (1 = initial level). */
36
+ currentLevel: number;
37
+ warnings: string[];
38
+ }
39
+
40
+ /** ISO date -> fractional year (2021-07-01 -> ~2021.5). */
41
+ function dateToYearFraction(iso: string): number {
42
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
43
+ if (!m) {
44
+ throw new ReservingError("BAD_DATE", `Not an ISO date: ${iso}`);
45
+ }
46
+ const year = Number(m[1]);
47
+ const start = Date.UTC(year, 0, 1);
48
+ const next = Date.UTC(year + 1, 0, 1);
49
+ const t = Date.UTC(year, Number(m[2]) - 1, Number(m[3]));
50
+ return year + (t - start) / (next - start);
51
+ }
52
+
53
+ /** Origin label -> [start, end) in fractional years ("2021" or "2021Q3"). */
54
+ function originInterval(origin: string): [number, number] {
55
+ const year = Number(origin.slice(0, 4));
56
+ if (!Number.isInteger(year)) {
57
+ throw new ReservingError("BAD_ORIGIN", `Cannot parse an origin period from "${origin}"`);
58
+ }
59
+ const qMatch = /Q([1-4])$/.exec(origin);
60
+ if (qMatch) {
61
+ const q = Number(qMatch[1]);
62
+ return [year + (q - 1) / 4, year + q / 4];
63
+ }
64
+ return [year, year + 1];
65
+ }
66
+
67
+ /**
68
+ * Earned-premium density of the period [p0, p1) by policy WRITTEN time w:
69
+ * overlap of the earning interval [w, w+1] with [p0, p1). Integrated exactly
70
+ * over [a, b] (the density is piecewise linear with breakpoints at p0-1,
71
+ * p1-1, p0, p1).
72
+ */
73
+ function earnedArea(p0: number, p1: number, a: number, b: number): number {
74
+ const lo = Math.max(a, p0 - 1);
75
+ const hi = Math.min(b, p1);
76
+ if (hi <= lo) return 0;
77
+ const density = (w: number): number =>
78
+ Math.max(0, Math.min(p1, w + 1) - Math.max(p0, w));
79
+ // Integrate exactly across the linear segments.
80
+ const breaks = [p0 - 1, p1 - 1, p0, p1]
81
+ .filter((x) => x > lo && x < hi)
82
+ .sort((x, y) => x - y);
83
+ const knots = [lo, ...breaks, hi];
84
+ let area = 0;
85
+ for (let i = 0; i + 1 < knots.length; i++) {
86
+ const x0 = knots[i]!;
87
+ const x1 = knots[i + 1]!;
88
+ area += ((density(x0) + density(x1)) / 2) * (x1 - x0); // exact: linear segment
89
+ }
90
+ return area;
91
+ }
92
+
93
+ /**
94
+ * On-level factors per origin period from a rate-change history. Changes
95
+ * effective before all data simply set the base level; an empty history
96
+ * yields factors of exactly 1.
97
+ */
98
+ export function parallelogramOnLevel(
99
+ origins: string[],
100
+ history: RateChange[],
101
+ ): OnLevelResult {
102
+ const warnings: string[] = [];
103
+ for (const rc of history) {
104
+ if (!isNum(rc.change) || rc.change <= -1) {
105
+ throw new ReservingError(
106
+ "BAD_RATE_CHANGE",
107
+ "A rate change must be a number greater than -100%",
108
+ );
109
+ }
110
+ }
111
+ const changes = [...history]
112
+ .map((rc) => ({ at: dateToYearFraction(rc.effectiveDate), change: rc.change }))
113
+ .sort((a, b) => a.at - b.at);
114
+
115
+ // Rate-level eras: [eraStart_i, eraStart_{i+1}) at cumulative level_i.
116
+ const eras: { from: number; level: number }[] = [{ from: -Infinity, level: 1 }];
117
+ let level = 1;
118
+ for (const c of changes) {
119
+ level *= 1 + c.change;
120
+ eras.push({ from: c.at, level });
121
+ }
122
+ const currentLevel = level;
123
+
124
+ const rows: OnLevelRow[] = origins.map((origin) => {
125
+ const [p0, p1] = originInterval(origin);
126
+ const total = earnedArea(p0, p1, -Infinity, p1);
127
+ if (!(total > 0)) {
128
+ throw new ReservingError("BAD_ORIGIN", `Origin "${origin}" has no earnable area`);
129
+ }
130
+ let weighted = 0;
131
+ for (let i = 0; i < eras.length; i++) {
132
+ const from = eras[i]!.from;
133
+ const to = i + 1 < eras.length ? eras[i + 1]!.from : Infinity;
134
+ const share = earnedArea(p0, p1, from, to) / total;
135
+ weighted += share * eras[i]!.level;
136
+ }
137
+ return {
138
+ origin,
139
+ averageRateLevel: weighted,
140
+ onLevelFactor: currentLevel / weighted,
141
+ };
142
+ });
143
+
144
+ if (changes.length > 0) {
145
+ const lastOriginEnd = Math.max(...origins.map((o) => originInterval(o)[1]));
146
+ const beyond = changes.filter((c) => c.at >= lastOriginEnd);
147
+ if (beyond.length > 0) {
148
+ warnings.push(
149
+ `${beyond.length} rate change(s) effective after the last origin period only move the current level (nothing historical to restate)`,
150
+ );
151
+ }
152
+ }
153
+
154
+ return { rows, currentLevel, warnings };
155
+ }
@@ -0,0 +1,205 @@
1
+ import { runChainLadder } from "./chainladder.js";
2
+ import type { ChainLadderResult, LdfSelections, Triangle, TriangleKind } from "./types.js";
3
+ import { ReservingError } from "./types.js";
4
+ import { isNum } from "./util.js";
5
+
6
+ /**
7
+ * Salvage & subrogation: recovery triangles develop like loss triangles, so
8
+ * ultimate recoveries come from a chain ladder on cumulative RECEIVED
9
+ * recoveries with the caller's selected factors, and net results come from
10
+ * subtracting the recovery projection from a gross one, aligned by origin.
11
+ *
12
+ * Ground truth:
13
+ * - Recovery development is commonly SLOWER and LUMPIER than the loss
14
+ * development it offsets (recoveries lag settlement); every run says so in
15
+ * warnings, because borrowing loss LDFs for recoveries is the classic
16
+ * mistake this module exists to prevent.
17
+ * - `netOfRecoveries` never fabricates: an origin present on only one side
18
+ * is EXCLUDED with a warning, and both sides' totals cover only the
19
+ * aligned origins so net = gross - recoveries holds exactly within the
20
+ * result.
21
+ * - Algebra identity (tested): with the same selections on both triangles,
22
+ * netting the two projections equals a chain ladder on the cell-wise
23
+ * difference triangle (subtractTriangles), because CL is linear in the
24
+ * latest diagonal.
25
+ */
26
+
27
+ export interface SalvageSubroOptions {
28
+ /** Selected recovery LDFs, one per development interval (null -> 1.000 with a warning). */
29
+ selected: LdfSelections["selected"];
30
+ tailFactor?: number;
31
+ }
32
+
33
+ export interface SalvageSubroRow {
34
+ origin: string;
35
+ latestAge: number;
36
+ /** Cumulative recoveries received as of the latest diagonal. */
37
+ receivedToDate: number;
38
+ /** Cumulative development factor from latestAge to ultimate. */
39
+ cdf: number;
40
+ ultimateRecoveries: number;
41
+ /** ultimate - received: recoveries still expected. */
42
+ futureRecoveries: number;
43
+ }
44
+
45
+ export interface SalvageSubroResult {
46
+ method: "salvageSubro";
47
+ basis: TriangleKind;
48
+ cdfs: number[];
49
+ percentDeveloped: number[];
50
+ rows: SalvageSubroRow[];
51
+ totals: { receivedToDate: number; ultimateRecoveries: number; futureRecoveries: number };
52
+ warnings: string[];
53
+ }
54
+
55
+ /** Chain ladder on a cumulative received-recoveries triangle. */
56
+ export function runSalvageSubro(
57
+ recoveries: Triangle,
58
+ options: SalvageSubroOptions,
59
+ ): SalvageSubroResult {
60
+ const warnings: string[] = [
61
+ "Salvage and subrogation typically develop more slowly and less smoothly than the losses they offset; select recovery factors from recovery experience rather than borrowing loss LDFs",
62
+ ];
63
+
64
+ const negativeOrigins = recoveries.origins.filter((_, i) =>
65
+ recoveries.values[i]!.some((v) => isNum(v ?? null) && v! < 0),
66
+ );
67
+ if (negativeOrigins.length > 0) {
68
+ warnings.push(
69
+ `Negative cumulative recoveries for origin(s) ${negativeOrigins.join(", ")}; received salvage/subrogation should be non-negative - verify the data`,
70
+ );
71
+ }
72
+
73
+ const cl = runChainLadder(recoveries, {
74
+ selected: options.selected,
75
+ tailFactor: options.tailFactor ?? 1,
76
+ });
77
+ warnings.push(...cl.warnings);
78
+
79
+ return {
80
+ method: "salvageSubro",
81
+ basis: cl.basis,
82
+ cdfs: cl.cdfs,
83
+ percentDeveloped: cl.percentDeveloped,
84
+ rows: cl.rows.map((r) => ({
85
+ origin: r.origin,
86
+ latestAge: r.latestAge,
87
+ receivedToDate: r.latestValue,
88
+ cdf: r.cdf,
89
+ ultimateRecoveries: r.ultimate,
90
+ futureRecoveries: r.unpaid,
91
+ })),
92
+ totals: {
93
+ receivedToDate: cl.totals.latest,
94
+ ultimateRecoveries: cl.totals.ultimate,
95
+ futureRecoveries: cl.totals.unpaid,
96
+ },
97
+ warnings,
98
+ };
99
+ }
100
+
101
+ export interface NetOfRecoveriesRow {
102
+ origin: string;
103
+ grossUltimate: number;
104
+ ultimateRecoveries: number;
105
+ netUltimate: number;
106
+ grossUnpaid: number;
107
+ futureRecoveries: number;
108
+ netUnpaid: number;
109
+ }
110
+
111
+ export interface NetOfRecoveriesResult {
112
+ method: "netOfRecoveries";
113
+ basis: TriangleKind;
114
+ rows: NetOfRecoveriesRow[];
115
+ totals: {
116
+ grossUltimate: number;
117
+ ultimateRecoveries: number;
118
+ netUltimate: number;
119
+ grossUnpaid: number;
120
+ futureRecoveries: number;
121
+ netUnpaid: number;
122
+ };
123
+ warnings: string[];
124
+ }
125
+
126
+ /**
127
+ * Nets a gross chain ladder against a recovery projection, aligned by origin
128
+ * label. Origins missing on either side are warned and EXCLUDED - never
129
+ * zero-filled - so the totals net exactly over the aligned origins.
130
+ */
131
+ export function netOfRecoveries(
132
+ gross: ChainLadderResult,
133
+ recoveries: SalvageSubroResult,
134
+ ): NetOfRecoveriesResult {
135
+ const warnings: string[] = [];
136
+ const recoveryByOrigin = new Map(recoveries.rows.map((r) => [r.origin, r]));
137
+ const grossOrigins = new Set(gross.rows.map((r) => r.origin));
138
+
139
+ const rows: NetOfRecoveriesRow[] = [];
140
+ const negativeNetOrigins: string[] = [];
141
+ for (const g of gross.rows) {
142
+ const rec = recoveryByOrigin.get(g.origin);
143
+ if (!rec) {
144
+ warnings.push(
145
+ `Origin ${g.origin} has no recovery projection; excluded from the net results (never zero-filled)`,
146
+ );
147
+ continue;
148
+ }
149
+ if (g.latestAge !== rec.latestAge) {
150
+ warnings.push(
151
+ `Origin ${g.origin}: gross projected from age ${g.latestAge} months but recoveries from age ${rec.latestAge}; the net mixes valuation dates`,
152
+ );
153
+ }
154
+ const row: NetOfRecoveriesRow = {
155
+ origin: g.origin,
156
+ grossUltimate: g.ultimate,
157
+ ultimateRecoveries: rec.ultimateRecoveries,
158
+ netUltimate: g.ultimate - rec.ultimateRecoveries,
159
+ grossUnpaid: g.unpaid,
160
+ futureRecoveries: rec.futureRecoveries,
161
+ netUnpaid: g.unpaid - rec.futureRecoveries,
162
+ };
163
+ if (row.netUltimate < 0 || row.netUnpaid < 0) negativeNetOrigins.push(g.origin);
164
+ rows.push(row);
165
+ }
166
+ for (const rec of recoveries.rows) {
167
+ if (!grossOrigins.has(rec.origin)) {
168
+ warnings.push(
169
+ `Origin ${rec.origin} has a recovery projection but no gross result; excluded from the net results (never zero-filled)`,
170
+ );
171
+ }
172
+ }
173
+ if (rows.length === 0) {
174
+ throw new ReservingError(
175
+ "NO_DATA",
176
+ "No origin appears in both the gross and recovery projections",
177
+ );
178
+ }
179
+ if (negativeNetOrigins.length > 0) {
180
+ warnings.push(
181
+ `Projected recoveries exceed gross losses for origin(s) ${negativeNetOrigins.join(", ")}; the net result is negative - verify the recovery development`,
182
+ );
183
+ }
184
+
185
+ const totals = rows.reduce(
186
+ (acc, r) => ({
187
+ grossUltimate: acc.grossUltimate + r.grossUltimate,
188
+ ultimateRecoveries: acc.ultimateRecoveries + r.ultimateRecoveries,
189
+ netUltimate: acc.netUltimate + r.netUltimate,
190
+ grossUnpaid: acc.grossUnpaid + r.grossUnpaid,
191
+ futureRecoveries: acc.futureRecoveries + r.futureRecoveries,
192
+ netUnpaid: acc.netUnpaid + r.netUnpaid,
193
+ }),
194
+ {
195
+ grossUltimate: 0,
196
+ ultimateRecoveries: 0,
197
+ netUltimate: 0,
198
+ grossUnpaid: 0,
199
+ futureRecoveries: 0,
200
+ netUnpaid: 0,
201
+ },
202
+ );
203
+
204
+ return { method: "netOfRecoveries", basis: gross.basis, rows, totals, warnings };
205
+ }