@actuarial-ts/core 0.2.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.
- package/README.md +11 -4
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +58 -18
- package/dist/mack.js.map +1 -1
- package/dist/odpBootstrap.d.ts +8 -2
- package/dist/odpBootstrap.d.ts.map +1 -1
- package/dist/odpBootstrap.js +11 -2
- package/dist/odpBootstrap.js.map +1 -1
- package/package.json +3 -1
- package/src/benktander.ts +90 -0
- package/src/berquist.ts +349 -0
- package/src/bf.ts +129 -0
- package/src/canonical.ts +122 -0
- package/src/capping.ts +295 -0
- package/src/caseOutstanding.ts +270 -0
- package/src/chainladder.ts +101 -0
- package/src/clark.ts +719 -0
- package/src/diagnostics.ts +435 -0
- package/src/discounting.ts +417 -0
- package/src/elrMethods.ts +257 -0
- package/src/factors.ts +147 -0
- package/src/fisherLange.ts +374 -0
- package/src/freqSev.ts +152 -0
- package/src/ilf.ts +567 -0
- package/src/index.ts +29 -0
- package/src/mack.ts +329 -0
- package/src/merzWuthrich.ts +147 -0
- package/src/munichChainLadder.ts +398 -0
- package/src/odpBootstrap.ts +337 -0
- package/src/onlevel.ts +155 -0
- package/src/salvageSubro.ts +205 -0
- package/src/stochastic.ts +151 -0
- package/src/tail.ts +156 -0
- package/src/trend.ts +150 -0
- package/src/triangle.ts +235 -0
- package/src/triangleAlgebra.ts +111 -0
- package/src/types.ts +357 -0
- package/src/ulae.ts +326 -0
- package/src/util.ts +68 -0
package/src/ilf.ts
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import type { ClaimSnapshot } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { isNum } from "./util.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Increased-limits machinery: severity distributions, limited expected
|
|
7
|
+
* values, and the uncap factor E[X] / E[X ∧ cap] that restores developed
|
|
8
|
+
* capped ultimates to total limits.
|
|
9
|
+
*
|
|
10
|
+
* Severities are fitted at the cap's BASE-YEAR cost level (each claim
|
|
11
|
+
* deflated by the layer's index), so an indexed cap - a constant layer in
|
|
12
|
+
* real terms - yields one uncap factor for all origin years. Under a flat
|
|
13
|
+
* cap with real severity trend the factor truly varies by year; the fit
|
|
14
|
+
* warns in that case, and per-year refinement arrives with the trend module.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Normal CDF (Abramowitz & Stegun 7.1.26; |error| < 1.5e-7)
|
|
19
|
+
|
|
20
|
+
function erf(x: number): number {
|
|
21
|
+
const sign = x < 0 ? -1 : 1;
|
|
22
|
+
const ax = Math.abs(x);
|
|
23
|
+
const t = 1 / (1 + 0.3275911 * ax);
|
|
24
|
+
const y =
|
|
25
|
+
1 -
|
|
26
|
+
(((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t +
|
|
27
|
+
0.254829592) *
|
|
28
|
+
t) *
|
|
29
|
+
Math.exp(-ax * ax);
|
|
30
|
+
return sign * y;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalCdf(z: number): number {
|
|
34
|
+
return 0.5 * (1 + erf(z / Math.SQRT2));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Severity distributions
|
|
39
|
+
|
|
40
|
+
export type SeverityDistribution =
|
|
41
|
+
| { kind: "lognormal"; mu: number; sigma: number }
|
|
42
|
+
| { kind: "pareto"; theta: number; alpha: number };
|
|
43
|
+
|
|
44
|
+
/** Mean E[X]; null when infinite (Pareto alpha <= 1). */
|
|
45
|
+
export function severityMean(dist: SeverityDistribution): number | null {
|
|
46
|
+
if (dist.kind === "lognormal") {
|
|
47
|
+
return Math.exp(dist.mu + (dist.sigma * dist.sigma) / 2);
|
|
48
|
+
}
|
|
49
|
+
if (dist.alpha <= 1) return null;
|
|
50
|
+
return dist.theta / (dist.alpha - 1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Limited expected value E[X ∧ c]. */
|
|
54
|
+
export function limitedExpectedValue(dist: SeverityDistribution, c: number): number {
|
|
55
|
+
if (!isNum(c) || c <= 0) {
|
|
56
|
+
throw new ReservingError("BAD_LIMIT", "The limit for a LEV must be a positive number");
|
|
57
|
+
}
|
|
58
|
+
if (dist.kind === "lognormal") {
|
|
59
|
+
const { mu, sigma } = dist;
|
|
60
|
+
const z1 = (Math.log(c) - mu - sigma * sigma) / sigma;
|
|
61
|
+
const z2 = (Math.log(c) - mu) / sigma;
|
|
62
|
+
return Math.exp(mu + (sigma * sigma) / 2) * normalCdf(z1) + c * (1 - normalCdf(z2));
|
|
63
|
+
}
|
|
64
|
+
const { theta, alpha } = dist;
|
|
65
|
+
if (Math.abs(alpha - 1) < 1e-9) {
|
|
66
|
+
// alpha -> 1 limit of the closed form: theta * ln((c + theta)/theta)
|
|
67
|
+
return theta * Math.log((c + theta) / theta);
|
|
68
|
+
}
|
|
69
|
+
return (theta / (alpha - 1)) * (1 - Math.pow(theta / (c + theta), alpha - 1));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The uncap factor to a target limit: E[X ∧ target] / E[X ∧ cap], with
|
|
74
|
+
* target null meaning unlimited (E[X] / E[X ∧ cap]). Throws when the
|
|
75
|
+
* distribution has no finite mean and target is unlimited.
|
|
76
|
+
*/
|
|
77
|
+
export function uncapFactor(
|
|
78
|
+
dist: SeverityDistribution,
|
|
79
|
+
cap: number,
|
|
80
|
+
target: number | null,
|
|
81
|
+
): number {
|
|
82
|
+
const denominator = limitedExpectedValue(dist, cap);
|
|
83
|
+
if (!(denominator > 0)) {
|
|
84
|
+
throw new ReservingError("BAD_LIMIT", "E[X ∧ cap] is not positive; check the fit and cap");
|
|
85
|
+
}
|
|
86
|
+
if (target === null) {
|
|
87
|
+
const mean = severityMean(dist);
|
|
88
|
+
if (mean === null) {
|
|
89
|
+
throw new ReservingError(
|
|
90
|
+
"INFINITE_MEAN",
|
|
91
|
+
"This Pareto fit has alpha <= 1 (infinite mean); an unlimited restoration is undefined - pick a finite target limit or a different curve",
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return mean / denominator;
|
|
95
|
+
}
|
|
96
|
+
if (target < cap) {
|
|
97
|
+
throw new ReservingError("BAD_LIMIT", "The restoration target must be at or above the cap");
|
|
98
|
+
}
|
|
99
|
+
const factor = limitedExpectedValue(dist, target) / denominator;
|
|
100
|
+
if (!Number.isFinite(factor)) {
|
|
101
|
+
throw new ReservingError(
|
|
102
|
+
"BAD_FIT",
|
|
103
|
+
"The fitted distribution produced a non-finite limited expected value (degenerate parameters); the fit is not usable for restoration",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return factor;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Censoring-aware MLE (closed claims exact, open claims right-censored)
|
|
111
|
+
|
|
112
|
+
export interface SeverityObservation {
|
|
113
|
+
/** Severity at the claim's latest evaluation, at base-year cost level. */
|
|
114
|
+
value: number;
|
|
115
|
+
/**
|
|
116
|
+
* True when the claim is still open. Its reported incurred is then treated
|
|
117
|
+
* as a right-censoring floor on ultimate severity - a CASE-ADEQUACY
|
|
118
|
+
* ASSUMPTION, not a fact: redundant case reserves overstate the fitted
|
|
119
|
+
* tail, deficient ones understate it. Fits must carry this caveat.
|
|
120
|
+
*/
|
|
121
|
+
censored: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface SeverityFit {
|
|
125
|
+
distribution: SeverityDistribution;
|
|
126
|
+
logLikelihood: number;
|
|
127
|
+
nExact: number;
|
|
128
|
+
nCensored: number;
|
|
129
|
+
nExcludedNonPositive: number;
|
|
130
|
+
valid: boolean;
|
|
131
|
+
warnings: string[];
|
|
132
|
+
/**
|
|
133
|
+
* Kaplan-Meier (censoring-adjusted) empirical quantiles vs fitted
|
|
134
|
+
* unconditional quantiles. Closed-claims-only quantiles are biased small
|
|
135
|
+
* (large claims stay open), so the empirical side uses the product-limit
|
|
136
|
+
* estimator; null where censoring exhausts the observable range.
|
|
137
|
+
*/
|
|
138
|
+
quantileCheck: { p: number; empirical: number | null; fitted: number }[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const QUANTILE_PROBES = [0.5, 0.75, 0.9, 0.95, 0.99];
|
|
142
|
+
|
|
143
|
+
function lognormalQuantile(mu: number, sigma: number, p: number): number {
|
|
144
|
+
// Inverse normal via Acklam's rational approximation (sufficient here).
|
|
145
|
+
const q = inverseNormalCdf(p);
|
|
146
|
+
return Math.exp(mu + sigma * q);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function paretoQuantile(theta: number, alpha: number, p: number): number {
|
|
150
|
+
return theta * (Math.pow(1 - p, -1 / alpha) - 1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function inverseNormalCdf(p: number): number {
|
|
154
|
+
// Acklam's algorithm; |relative error| < 1.15e-9 over (0,1).
|
|
155
|
+
if (p <= 0 || p >= 1) throw new ReservingError("BAD_LIMIT", "quantile p must be in (0,1)");
|
|
156
|
+
const a = [-39.6968302866538, 220.946098424521, -275.928510446969, 138.357751867269,
|
|
157
|
+
-30.6647980661472, 2.50662827745924];
|
|
158
|
+
const b = [-54.4760987982241, 161.585836858041, -155.698979859887, 66.8013118877197,
|
|
159
|
+
-13.2806815528857];
|
|
160
|
+
const c = [-0.00778489400243029, -0.322396458041136, -2.40075827716184, -2.54973253934373,
|
|
161
|
+
4.37466414146497, 2.93816398269878];
|
|
162
|
+
const d = [0.00778469570904146, 0.32246712907004, 2.445134137143, 3.75440866190742];
|
|
163
|
+
const pLow = 0.02425;
|
|
164
|
+
let q: number;
|
|
165
|
+
let r: number;
|
|
166
|
+
if (p < pLow) {
|
|
167
|
+
q = Math.sqrt(-2 * Math.log(p));
|
|
168
|
+
return (((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
|
|
169
|
+
((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1);
|
|
170
|
+
}
|
|
171
|
+
if (p <= 1 - pLow) {
|
|
172
|
+
q = p - 0.5;
|
|
173
|
+
r = q * q;
|
|
174
|
+
return ((((((a[0]! * r + a[1]!) * r + a[2]!) * r + a[3]!) * r + a[4]!) * r + a[5]!) * q) /
|
|
175
|
+
(((((b[0]! * r + b[1]!) * r + b[2]!) * r + b[3]!) * r + b[4]!) * r + 1);
|
|
176
|
+
}
|
|
177
|
+
q = Math.sqrt(-2 * Math.log(1 - p));
|
|
178
|
+
return -(((((c[0]! * q + c[1]!) * q + c[2]!) * q + c[3]!) * q + c[4]!) * q + c[5]!) /
|
|
179
|
+
((((d[0]! * q + d[1]!) * q + d[2]!) * q + d[3]!) * q + 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Deterministic Nelder-Mead in 2D (fixed iteration budget, no randomness). */
|
|
183
|
+
function nelderMead2(
|
|
184
|
+
f: (p: [number, number]) => number,
|
|
185
|
+
start: [number, number],
|
|
186
|
+
scale: [number, number],
|
|
187
|
+
iterations = 400,
|
|
188
|
+
): [number, number] {
|
|
189
|
+
type Pt = { x: [number, number]; v: number };
|
|
190
|
+
const mk = (x: [number, number]): Pt => ({ x, v: f(x) });
|
|
191
|
+
let simplex: Pt[] = [
|
|
192
|
+
mk(start),
|
|
193
|
+
mk([start[0] + scale[0], start[1]]),
|
|
194
|
+
mk([start[0], start[1] + scale[1]]),
|
|
195
|
+
];
|
|
196
|
+
for (let it = 0; it < iterations; it++) {
|
|
197
|
+
simplex.sort((p, q) => p.v - q.v);
|
|
198
|
+
const [best, mid, worst] = simplex as [Pt, Pt, Pt];
|
|
199
|
+
const centroid: [number, number] = [
|
|
200
|
+
(best.x[0] + mid.x[0]) / 2,
|
|
201
|
+
(best.x[1] + mid.x[1]) / 2,
|
|
202
|
+
];
|
|
203
|
+
const reflect = mk([
|
|
204
|
+
centroid[0] + (centroid[0] - worst.x[0]),
|
|
205
|
+
centroid[1] + (centroid[1] - worst.x[1]),
|
|
206
|
+
]);
|
|
207
|
+
if (reflect.v < best.v) {
|
|
208
|
+
const expand = mk([
|
|
209
|
+
centroid[0] + 2 * (centroid[0] - worst.x[0]),
|
|
210
|
+
centroid[1] + 2 * (centroid[1] - worst.x[1]),
|
|
211
|
+
]);
|
|
212
|
+
simplex[2] = expand.v < reflect.v ? expand : reflect;
|
|
213
|
+
} else if (reflect.v < mid.v) {
|
|
214
|
+
simplex[2] = reflect;
|
|
215
|
+
} else {
|
|
216
|
+
const contract = mk([
|
|
217
|
+
centroid[0] + 0.5 * (worst.x[0] - centroid[0]),
|
|
218
|
+
centroid[1] + 0.5 * (worst.x[1] - centroid[1]),
|
|
219
|
+
]);
|
|
220
|
+
if (contract.v < worst.v) {
|
|
221
|
+
simplex[2] = contract;
|
|
222
|
+
} else {
|
|
223
|
+
// Shrink toward the best point.
|
|
224
|
+
simplex = [
|
|
225
|
+
best,
|
|
226
|
+
mk([(best.x[0] + mid.x[0]) / 2, (best.x[1] + mid.x[1]) / 2]),
|
|
227
|
+
mk([(best.x[0] + worst.x[0]) / 2, (best.x[1] + worst.x[1]) / 2]),
|
|
228
|
+
];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
simplex.sort((p, q) => p.v - q.v);
|
|
233
|
+
return simplex[0]!.x;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const MIN_OBSERVATIONS = 10;
|
|
237
|
+
const THIN_OBSERVATIONS = 30;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fits a severity distribution by maximum likelihood with right-censoring:
|
|
241
|
+
* closed claims contribute the density, open claims the survival at their
|
|
242
|
+
* reported incurred. Treating reported incurred as a floor on ultimate
|
|
243
|
+
* severity is a CASE-ADEQUACY assumption - on books with redundant reserves
|
|
244
|
+
* the fitted tail (and any uncap factor from it) is overstated. Fits are
|
|
245
|
+
* additionally gated on censored share and parameter sanity: a likelihood
|
|
246
|
+
* dominated by open claims can push the tail arbitrarily heavy while looking
|
|
247
|
+
* converged.
|
|
248
|
+
*/
|
|
249
|
+
export function fitSeverity(
|
|
250
|
+
observations: SeverityObservation[],
|
|
251
|
+
kind: SeverityDistribution["kind"],
|
|
252
|
+
): SeverityFit {
|
|
253
|
+
const warnings: string[] = [];
|
|
254
|
+
const usable = observations.filter((o) => isNum(o.value) && o.value > 0);
|
|
255
|
+
const nExcluded = observations.length - usable.length;
|
|
256
|
+
const exact = usable.filter((o) => !o.censored).map((o) => o.value);
|
|
257
|
+
const censored = usable.filter((o) => o.censored).map((o) => o.value);
|
|
258
|
+
|
|
259
|
+
const invalid = (message: string): SeverityFit => ({
|
|
260
|
+
distribution:
|
|
261
|
+
kind === "lognormal"
|
|
262
|
+
? { kind: "lognormal", mu: 0, sigma: 1 }
|
|
263
|
+
: { kind: "pareto", theta: 1, alpha: 2 },
|
|
264
|
+
logLikelihood: NaN,
|
|
265
|
+
nExact: exact.length,
|
|
266
|
+
nCensored: censored.length,
|
|
267
|
+
nExcludedNonPositive: nExcluded,
|
|
268
|
+
valid: false,
|
|
269
|
+
warnings: [...warnings, message],
|
|
270
|
+
quantileCheck: [],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (usable.length < MIN_OBSERVATIONS) {
|
|
274
|
+
return invalid(
|
|
275
|
+
`Only ${usable.length} usable claim severities; at least ${MIN_OBSERVATIONS} are needed for a credible fit`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (exact.length < 5) {
|
|
279
|
+
return invalid(
|
|
280
|
+
`Only ${exact.length} closed (uncensored) claims; the likelihood is dominated by censoring and the fit is not credible`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const censoredShare = censored.length / usable.length;
|
|
284
|
+
if (censoredShare > 0.8) {
|
|
285
|
+
return invalid(
|
|
286
|
+
`${Math.round(censoredShare * 100)}% of usable claims are open (right-censored): the likelihood is censoring-dominated and the fitted tail is not credible`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (censoredShare > 0.5) {
|
|
290
|
+
warnings.push(
|
|
291
|
+
`${Math.round(censoredShare * 100)}% of usable claims are open: the fit leans heavily on the case-adequacy assumption; treat the tail with caution`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (usable.length < THIN_OBSERVATIONS) {
|
|
295
|
+
warnings.push(
|
|
296
|
+
`Only ${usable.length} usable claim severities; treat the fitted tail with caution`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (nExcluded > 0) {
|
|
300
|
+
warnings.push(`${nExcluded} zero/negative severities excluded from the fit`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let dist: SeverityDistribution;
|
|
304
|
+
let logLik: number;
|
|
305
|
+
|
|
306
|
+
if (kind === "lognormal") {
|
|
307
|
+
const negLogLik = (p: [number, number]): number => {
|
|
308
|
+
const mu = p[0];
|
|
309
|
+
const sigma = Math.exp(p[1]); // positivity via log-space
|
|
310
|
+
let ll = 0;
|
|
311
|
+
for (const x of exact) {
|
|
312
|
+
const z = (Math.log(x) - mu) / sigma;
|
|
313
|
+
ll += -Math.log(sigma * x) - 0.5 * Math.log(2 * Math.PI) - 0.5 * z * z;
|
|
314
|
+
}
|
|
315
|
+
for (const x of censored) {
|
|
316
|
+
const s = 1 - normalCdf((Math.log(x) - mu) / sigma);
|
|
317
|
+
ll += Math.log(Math.max(s, 1e-300));
|
|
318
|
+
}
|
|
319
|
+
return -ll;
|
|
320
|
+
};
|
|
321
|
+
// Method-of-moments start on log(exact).
|
|
322
|
+
const logs = exact.map((x) => Math.log(x));
|
|
323
|
+
const m = logs.reduce((a, v) => a + v, 0) / logs.length;
|
|
324
|
+
const sd = Math.sqrt(
|
|
325
|
+
Math.max(1e-6, logs.reduce((a, v) => a + (v - m) ** 2, 0) / Math.max(1, logs.length - 1)),
|
|
326
|
+
);
|
|
327
|
+
const best = nelderMead2(negLogLik, [m, Math.log(sd)], [0.5, 0.5]);
|
|
328
|
+
dist = { kind: "lognormal", mu: best[0], sigma: Math.exp(best[1]) };
|
|
329
|
+
logLik = -negLogLik(best);
|
|
330
|
+
} else {
|
|
331
|
+
const negLogLik = (p: [number, number]): number => {
|
|
332
|
+
const theta = Math.exp(p[0]);
|
|
333
|
+
const alpha = Math.exp(p[1]);
|
|
334
|
+
let ll = 0;
|
|
335
|
+
for (const x of exact) {
|
|
336
|
+
ll += Math.log(alpha) + alpha * Math.log(theta) - (alpha + 1) * Math.log(x + theta);
|
|
337
|
+
}
|
|
338
|
+
for (const x of censored) {
|
|
339
|
+
ll += alpha * (Math.log(theta) - Math.log(x + theta));
|
|
340
|
+
}
|
|
341
|
+
return -ll;
|
|
342
|
+
};
|
|
343
|
+
const mean = exact.reduce((a, v) => a + v, 0) / exact.length;
|
|
344
|
+
const best = nelderMead2(negLogLik, [Math.log(mean), Math.log(2)], [1, 0.5]);
|
|
345
|
+
dist = { kind: "pareto", theta: Math.exp(best[0]), alpha: Math.exp(best[1]) };
|
|
346
|
+
logLik = -negLogLik(best);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Parameter-sanity gates: an optimizer that "converged" onto a degenerate
|
|
350
|
+
// or implausibly heavy tail is a failed fit for restoration purposes.
|
|
351
|
+
const problems: string[] = [];
|
|
352
|
+
if (!Number.isFinite(logLik)) {
|
|
353
|
+
problems.push("The likelihood did not converge to a finite value");
|
|
354
|
+
}
|
|
355
|
+
if (dist.kind === "lognormal") {
|
|
356
|
+
if (dist.sigma < 1e-6) {
|
|
357
|
+
problems.push("Fitted sigma is ~0 (degenerate point mass); the fit is not usable");
|
|
358
|
+
} else if (dist.sigma > 3.5) {
|
|
359
|
+
problems.push(
|
|
360
|
+
`Fitted lognormal sigma ${dist.sigma.toFixed(2)} implies an implausibly heavy tail - typically a censoring-dominated likelihood; the fit is not usable for restoration`,
|
|
361
|
+
);
|
|
362
|
+
} else if (dist.sigma > 2.5) {
|
|
363
|
+
warnings.push(
|
|
364
|
+
`Fitted lognormal sigma ${dist.sigma.toFixed(2)} is very heavy for casualty severity; verify against the claim-size distribution before restoring with it`,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
if (dist.alpha <= 1) {
|
|
369
|
+
problems.push(
|
|
370
|
+
`Fitted Pareto alpha ${dist.alpha.toFixed(3)} <= 1 (infinite mean): the fit is not usable for restoration at ANY target`,
|
|
371
|
+
);
|
|
372
|
+
} else if (dist.alpha < 1.2) {
|
|
373
|
+
warnings.push(
|
|
374
|
+
`Fitted Pareto alpha ${dist.alpha.toFixed(3)} is an extremely heavy tail; verify before restoring with it`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const kmQuantiles = kaplanMeierQuantiles(exact, censored, QUANTILE_PROBES);
|
|
380
|
+
const quantileCheck = QUANTILE_PROBES.map((p, i) => ({
|
|
381
|
+
p,
|
|
382
|
+
empirical: kmQuantiles[i]!,
|
|
383
|
+
fitted:
|
|
384
|
+
dist.kind === "lognormal"
|
|
385
|
+
? lognormalQuantile(dist.mu, dist.sigma, p)
|
|
386
|
+
: paretoQuantile(dist.theta, dist.alpha, p),
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
distribution: dist,
|
|
391
|
+
logLikelihood: logLik,
|
|
392
|
+
nExact: exact.length,
|
|
393
|
+
nCensored: censored.length,
|
|
394
|
+
nExcludedNonPositive: nExcluded,
|
|
395
|
+
valid: problems.length === 0,
|
|
396
|
+
warnings: [...warnings, ...problems],
|
|
397
|
+
quantileCheck,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Kaplan-Meier product-limit quantiles over exact + right-censored values:
|
|
403
|
+
* the censoring-adjusted empirical benchmark (closed-only quantiles are
|
|
404
|
+
* biased small because large claims stay open). Returns null for probes the
|
|
405
|
+
* survival curve cannot reach (censoring exhausts the observable range).
|
|
406
|
+
* Ties convention: events precede censorings at the same value.
|
|
407
|
+
*/
|
|
408
|
+
export function kaplanMeierQuantiles(
|
|
409
|
+
exact: number[],
|
|
410
|
+
censored: number[],
|
|
411
|
+
probes: number[],
|
|
412
|
+
): (number | null)[] {
|
|
413
|
+
const events = [...exact].sort((a, b) => a - b);
|
|
414
|
+
const all = [
|
|
415
|
+
...exact.map((v) => ({ v, event: true })),
|
|
416
|
+
...censored.map((v) => ({ v, event: false })),
|
|
417
|
+
];
|
|
418
|
+
const n = all.length;
|
|
419
|
+
if (n === 0 || events.length === 0) return probes.map(() => null);
|
|
420
|
+
|
|
421
|
+
// Survival steps at each distinct event value.
|
|
422
|
+
const steps: { value: number; survival: number }[] = [];
|
|
423
|
+
let survival = 1;
|
|
424
|
+
const distinct = [...new Set(events)];
|
|
425
|
+
for (const t of distinct) {
|
|
426
|
+
const atRisk = all.filter((o) => o.v >= t).length; // censored ties still at risk
|
|
427
|
+
const d = events.filter((v) => v === t).length;
|
|
428
|
+
if (atRisk <= 0) break;
|
|
429
|
+
survival *= 1 - d / atRisk;
|
|
430
|
+
steps.push({ value: t, survival });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return probes.map((p) => {
|
|
434
|
+
const threshold = 1 - p;
|
|
435
|
+
for (const step of steps) {
|
|
436
|
+
if (step.survival <= threshold + 1e-12) return step.value;
|
|
437
|
+
}
|
|
438
|
+
return null; // curve never falls this far: censored beyond observable range
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Structural validation shared by every ILF-table write path (import route,
|
|
444
|
+
* workspace patch) and by interpolation: >= 2 rows, positive limits and
|
|
445
|
+
* factors, unique limits, factors non-decreasing in the limit. Throws
|
|
446
|
+
* ReservingError so garbage is rejected at the door, identically everywhere.
|
|
447
|
+
*/
|
|
448
|
+
export function validateIlfTable(table: IlfTableRow[]): IlfTableRow[] {
|
|
449
|
+
if (!Array.isArray(table) || table.length < 2) {
|
|
450
|
+
throw new ReservingError("BAD_TABLE", "An ILF table needs at least two limit/factor rows");
|
|
451
|
+
}
|
|
452
|
+
const rows = [...table].sort((a, b) => a.limit - b.limit);
|
|
453
|
+
for (const row of rows) {
|
|
454
|
+
if (!isNum(row.limit) || row.limit <= 0 || !isNum(row.factor) || row.factor <= 0) {
|
|
455
|
+
throw new ReservingError("BAD_TABLE", "ILF rows must have positive limits and factors");
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
for (let i = 1; i < rows.length; i++) {
|
|
459
|
+
if (rows[i]!.limit === rows[i - 1]!.limit) {
|
|
460
|
+
throw new ReservingError(
|
|
461
|
+
"BAD_TABLE",
|
|
462
|
+
`Duplicate limit ${rows[i]!.limit.toLocaleString("en-US")} in the ILF table`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (rows[i]!.factor < rows[i - 1]!.factor) {
|
|
466
|
+
throw new ReservingError("BAD_TABLE", "ILF factors must be non-decreasing in the limit");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return rows;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Base-year-level severity observations from a loss run: each claim's latest
|
|
474
|
+
* evaluation on or before asOfDate, deflated by the cap index, open claims
|
|
475
|
+
* censored at reported incurred.
|
|
476
|
+
*/
|
|
477
|
+
export function severityObservations(
|
|
478
|
+
claims: ClaimSnapshot[],
|
|
479
|
+
options: { asOfDate?: string; indexRate?: number; baseYear: number },
|
|
480
|
+
): SeverityObservation[] {
|
|
481
|
+
const asOf = options.asOfDate ?? "9999-12-31";
|
|
482
|
+
const indexRate = options.indexRate ?? 0;
|
|
483
|
+
const latest = new Map<string, ClaimSnapshot>();
|
|
484
|
+
for (const snap of claims) {
|
|
485
|
+
if (snap.evaluationDate > asOf) continue;
|
|
486
|
+
const prev = latest.get(snap.claimId);
|
|
487
|
+
if (!prev || snap.evaluationDate > prev.evaluationDate) latest.set(snap.claimId, snap);
|
|
488
|
+
}
|
|
489
|
+
return [...latest.values()].map((snap) => {
|
|
490
|
+
const year = Number(snap.accidentDate.slice(0, 4));
|
|
491
|
+
const incurred = snap.paidToDate + snap.caseReserve;
|
|
492
|
+
return {
|
|
493
|
+
value: incurred / Math.pow(1 + indexRate, year - options.baseYear),
|
|
494
|
+
censored: snap.status === "open",
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// ILF tables (imported) and illustrative curves
|
|
501
|
+
|
|
502
|
+
export interface IlfTableRow {
|
|
503
|
+
limit: number;
|
|
504
|
+
factor: number;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Log-log interpolated ILF at a limit. Exact at knots; throws when the
|
|
509
|
+
* table does not bracket the requested limit (extrapolating an ILF table is
|
|
510
|
+
* how excess layers get silently mispriced).
|
|
511
|
+
*/
|
|
512
|
+
export function interpolateIlf(table: IlfTableRow[], limit: number): number {
|
|
513
|
+
const rows = validateIlfTable(table);
|
|
514
|
+
if (limit < rows[0]!.limit || limit > rows[rows.length - 1]!.limit) {
|
|
515
|
+
throw new ReservingError(
|
|
516
|
+
"TABLE_RANGE",
|
|
517
|
+
`The table covers limits ${rows[0]!.limit.toLocaleString("en-US")} to ${rows[rows.length - 1]!.limit.toLocaleString("en-US")}; ${limit.toLocaleString("en-US")} is outside it`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
for (let i = 1; i < rows.length; i++) {
|
|
521
|
+
const lo = rows[i - 1]!;
|
|
522
|
+
const hi = rows[i]!;
|
|
523
|
+
if (limit === lo.limit) return lo.factor;
|
|
524
|
+
if (limit === hi.limit) return hi.factor;
|
|
525
|
+
if (limit > lo.limit && limit < hi.limit) {
|
|
526
|
+
const t = (Math.log(limit) - Math.log(lo.limit)) / (Math.log(hi.limit) - Math.log(lo.limit));
|
|
527
|
+
return Math.exp(Math.log(lo.factor) + t * (Math.log(hi.factor) - Math.log(lo.factor)));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// Unreachable: the range check above guarantees the loop returns.
|
|
531
|
+
throw new ReservingError("TABLE_RANGE", "ILF interpolation failed to bracket a validated limit");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Table-based uncap factor: ILF(target)/ILF(cap). */
|
|
535
|
+
export function tableUncapFactor(table: IlfTableRow[], cap: number, target: number): number {
|
|
536
|
+
if (target < cap) {
|
|
537
|
+
throw new ReservingError("BAD_LIMIT", "The restoration target must be at or above the cap");
|
|
538
|
+
}
|
|
539
|
+
return interpolateIlf(table, target) / interpolateIlf(table, cap);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Illustrative severity curves for when no licensed table and no credible
|
|
544
|
+
* own-data fit exist. Parameters are textbook-plausible shapes, NOT ISO or
|
|
545
|
+
* NCCI factors - the UI must say so loudly.
|
|
546
|
+
*/
|
|
547
|
+
export const ILLUSTRATIVE_CURVES: {
|
|
548
|
+
id: string;
|
|
549
|
+
label: string;
|
|
550
|
+
distribution: SeverityDistribution;
|
|
551
|
+
}[] = [
|
|
552
|
+
{
|
|
553
|
+
id: "casualty-lognormal-moderate",
|
|
554
|
+
label: "Illustrative casualty severity - lognormal, moderate tail",
|
|
555
|
+
distribution: { kind: "lognormal", mu: 9.2, sigma: 1.4 },
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
id: "casualty-lognormal-heavy",
|
|
559
|
+
label: "Illustrative casualty severity - lognormal, heavy tail",
|
|
560
|
+
distribution: { kind: "lognormal", mu: 9.0, sigma: 1.8 },
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: "liability-pareto",
|
|
564
|
+
label: "Illustrative liability severity - Pareto (theta 40k, alpha 1.8)",
|
|
565
|
+
distribution: { kind: "pareto", theta: 40_000, alpha: 1.8 },
|
|
566
|
+
},
|
|
567
|
+
];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./util.js";
|
|
3
|
+
export * from "./canonical.js";
|
|
4
|
+
export * from "./triangle.js";
|
|
5
|
+
export * from "./factors.js";
|
|
6
|
+
export * from "./chainladder.js";
|
|
7
|
+
export * from "./bf.js";
|
|
8
|
+
export * from "./benktander.js";
|
|
9
|
+
export * from "./freqSev.js";
|
|
10
|
+
export * from "./stochastic.js";
|
|
11
|
+
export * from "./triangleAlgebra.js";
|
|
12
|
+
export * from "./odpBootstrap.js";
|
|
13
|
+
export * from "./tail.js";
|
|
14
|
+
export * from "./berquist.js";
|
|
15
|
+
export * from "./clark.js";
|
|
16
|
+
export * from "./diagnostics.js";
|
|
17
|
+
export * from "./mack.js";
|
|
18
|
+
export * from "./capping.js";
|
|
19
|
+
export * from "./ilf.js";
|
|
20
|
+
export * from "./trend.js";
|
|
21
|
+
export * from "./onlevel.js";
|
|
22
|
+
export * from "./elrMethods.js";
|
|
23
|
+
export * from "./merzWuthrich.js";
|
|
24
|
+
export * from "./munichChainLadder.js";
|
|
25
|
+
export * from "./ulae.js";
|
|
26
|
+
export * from "./discounting.js";
|
|
27
|
+
export * from "./caseOutstanding.js";
|
|
28
|
+
export * from "./fisherLange.js";
|
|
29
|
+
export * from "./salvageSubro.js";
|