@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,111 @@
1
+ import type { Triangle } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum } from "./util.js";
4
+
5
+ /**
6
+ * Triangle algebra: incremental <-> cumulative conversion and cell-wise
7
+ * arithmetic. Prerequisites for the stochastic methods (the ODP model works
8
+ * on incrementals) and for gross/ceded/net and paid+case identities.
9
+ *
10
+ * Ground truth:
11
+ * - cumulativeToIncremental: an interior null makes the increments touching
12
+ * it undefined - the hole's cell and the cell immediately after it are
13
+ * null, and increments RESUME wherever two consecutive cells are both
14
+ * observed. Nothing is ever fabricated to bridge a hole.
15
+ * - incrementalToCumulative: accumulation stops at the FIRST null in a row
16
+ * (a later observed increment has no defined cumulative base).
17
+ * - Incremental[0] = cumulative[0] (the first cell is its own increment).
18
+ */
19
+
20
+ function sameShape(a: Triangle, b: Triangle): boolean {
21
+ return (
22
+ a.origins.length === b.origins.length &&
23
+ a.origins.every((o, i) => o === b.origins[i]) &&
24
+ a.ages.length === b.ages.length &&
25
+ a.ages.every((v, j) => v === b.ages[j])
26
+ );
27
+ }
28
+
29
+ function assertShape(a: Triangle, b: Triangle): void {
30
+ if (!sameShape(a, b)) {
31
+ throw new ReservingError(
32
+ "SHAPE",
33
+ "Triangle algebra requires identical origins and development ages",
34
+ );
35
+ }
36
+ }
37
+
38
+ /** Cumulative -> incremental. First cell passes through; nulls propagate. */
39
+ export function cumulativeToIncremental(tri: Triangle): Triangle {
40
+ return {
41
+ kind: tri.kind,
42
+ origins: [...tri.origins],
43
+ ages: [...tri.ages],
44
+ values: tri.values.map((row) => {
45
+ const out: (number | null)[] = new Array(row.length).fill(null);
46
+ for (let j = 0; j < row.length; j++) {
47
+ const cur = row[j] ?? null;
48
+ if (!isNum(cur)) continue;
49
+ if (j === 0) {
50
+ out[0] = cur;
51
+ continue;
52
+ }
53
+ const prev = row[j - 1] ?? null;
54
+ out[j] = isNum(prev) ? cur - prev : null;
55
+ }
56
+ return out;
57
+ }),
58
+ };
59
+ }
60
+
61
+ /** Incremental -> cumulative. A null stops accumulation for the row. */
62
+ export function incrementalToCumulative(tri: Triangle): Triangle {
63
+ return {
64
+ kind: tri.kind,
65
+ origins: [...tri.origins],
66
+ ages: [...tri.ages],
67
+ values: tri.values.map((row) => {
68
+ const out: (number | null)[] = new Array(row.length).fill(null);
69
+ let running: number | null = null;
70
+ for (let j = 0; j < row.length; j++) {
71
+ const v = row[j] ?? null;
72
+ if (!isNum(v)) break;
73
+ running = (running ?? 0) + v;
74
+ out[j] = running;
75
+ }
76
+ return out;
77
+ }),
78
+ };
79
+ }
80
+
81
+ /** Cell-wise a + b; null wherever either side is null. */
82
+ export function addTriangles(a: Triangle, b: Triangle): Triangle {
83
+ assertShape(a, b);
84
+ return {
85
+ kind: a.kind,
86
+ origins: [...a.origins],
87
+ ages: [...a.ages],
88
+ values: a.values.map((row, i) =>
89
+ row.map((v, j) => {
90
+ const w = b.values[i]![j] ?? null;
91
+ return isNum(v ?? null) && isNum(w) ? v! + w : null;
92
+ }),
93
+ ),
94
+ };
95
+ }
96
+
97
+ /** Cell-wise a - b (gross - ceded = net); null wherever either side is null. */
98
+ export function subtractTriangles(a: Triangle, b: Triangle): Triangle {
99
+ assertShape(a, b);
100
+ return {
101
+ kind: a.kind,
102
+ origins: [...a.origins],
103
+ ages: [...a.ages],
104
+ values: a.values.map((row, i) =>
105
+ row.map((v, j) => {
106
+ const w = b.values[i]![j] ?? null;
107
+ return isNum(v ?? null) && isNum(w) ? v! - w : null;
108
+ }),
109
+ ),
110
+ };
111
+ }
package/src/types.ts ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Core domain types for the reserving engine.
3
+ *
4
+ * Triangle semantics (ground truth for the whole engine):
5
+ * - rows = origin periods, columns = development ages
6
+ * - cells not yet observable are null
7
+ * - every computation must be null-safe; division by a missing, zero, or
8
+ * negative denominator yields "no factor" (null), never an exception or NaN
9
+ */
10
+
11
+ /** Cadence of origin periods. */
12
+ export type OriginCadence = "annual" | "quarterly";
13
+
14
+ /** The kinds of triangles the engine knows how to build and analyze. */
15
+ export type TriangleKind =
16
+ | "paid"
17
+ | "incurred"
18
+ | "caseReserve"
19
+ | "reportedCount"
20
+ | "openCount"
21
+ | "closedCount"
22
+ | "closedWithPayCount";
23
+
24
+ export interface Triangle {
25
+ kind: TriangleKind;
26
+ /** Human-readable origin period labels, ascending (e.g. "2019", "2021Q3"). */
27
+ origins: string[];
28
+ /** Development ages in months, ascending (e.g. [12, 24, 36] or [3, 6, 9]). */
29
+ ages: number[];
30
+ /** values[originIndex][ageIndex]; null = not yet observable / missing. */
31
+ values: (number | null)[][];
32
+ }
33
+
34
+ /** A single claim evaluation snapshot: one row per claim per evaluation date. */
35
+ export interface ClaimSnapshot {
36
+ claimId: string;
37
+ /** ISO date (yyyy-mm-dd) the loss occurred. */
38
+ accidentDate: string;
39
+ /** ISO date the claim was reported to the insurer. */
40
+ reportDate: string;
41
+ /** ISO date this snapshot was evaluated. */
42
+ evaluationDate: string;
43
+ /** Cumulative paid loss as of the evaluation date. */
44
+ paidToDate: number;
45
+ /** Outstanding case reserve as of the evaluation date. */
46
+ caseReserve: number;
47
+ /** Claim status as of the evaluation date. */
48
+ status: "open" | "closed";
49
+ }
50
+
51
+ /**
52
+ * Exposure data by origin period. A period may carry earned premium (the base
53
+ * for the loss-ratio method), exposure units (the base for the pure-premium
54
+ * method), or both. The reserving methods are base-agnostic: the caller feeds
55
+ * whichever base the chosen method uses into `earnedPremium`.
56
+ */
57
+ export interface ExposureRecord {
58
+ /** Origin period label matching triangle origins (e.g. "2021" or "2021Q3"). */
59
+ origin: string;
60
+ /** Earned premium for the period (the loss-ratio base); null if not imported. */
61
+ earnedPremium: number | null;
62
+ /** Exposure units for the period (the pure-premium base); null if not imported. */
63
+ exposureUnits: number | null;
64
+ }
65
+
66
+ /** How a link-ratio average is computed for a development column. */
67
+ /**
68
+ * Keys of the standard averages menu (DEFAULT_AVERAGES). Custom AverageSpec
69
+ * keys remain legal; these are the ones every exhibit and consumer can rely
70
+ * on being present.
71
+ */
72
+ export const AVERAGE_KEYS = [
73
+ "all-wtd",
74
+ "all-str",
75
+ "5-wtd",
76
+ "5-str",
77
+ "3-wtd",
78
+ "3-str",
79
+ "med-5x1",
80
+ "geo-all",
81
+ ] as const;
82
+
83
+ export type AverageKey = (typeof AVERAGE_KEYS)[number];
84
+
85
+ export interface AverageSpec {
86
+ /** One of AVERAGE_KEYS for the standard menu; custom keys are permitted. */
87
+ key: AverageKey | (string & {});
88
+ label: string;
89
+ kind: "straight" | "weighted" | "medial" | "geometric";
90
+ /** Number of most recent origin periods to include; omit for all-year. */
91
+ years?: number;
92
+ }
93
+
94
+ /** Age-to-age factors for one triangle. */
95
+ export interface DevelopmentFactors {
96
+ /** For column j: development from ages[j] to ages[j+1]. Length = ages.length - 1. */
97
+ fromAges: number[];
98
+ toAges: number[];
99
+ /** individual[originIndex][columnIndex]; null where not computable. */
100
+ individual: (number | null)[][];
101
+ /** Per-average-key, per-column computed averages; null where not computable. */
102
+ averages: { spec: AverageSpec; values: (number | null)[] }[];
103
+ }
104
+
105
+ /** Per-column LDF selection made by the user or the advisor. */
106
+ export interface LdfSelections {
107
+ /** selected[j] = LDF for development column j; null = not selected. */
108
+ selected: (number | null)[];
109
+ tailFactor: number;
110
+ }
111
+
112
+ export interface ChainLadderRow {
113
+ origin: string;
114
+ /** Age (months) of the latest observed diagonal cell for this origin. */
115
+ latestAge: number;
116
+ /** Value on the latest observed diagonal. */
117
+ latestValue: number;
118
+ /** Cumulative development factor from latestAge to ultimate. */
119
+ cdf: number;
120
+ percentDeveloped: number;
121
+ ultimate: number;
122
+ /** ultimate - latestValue (IBNR on incurred basis; unpaid on paid basis). */
123
+ unpaid: number;
124
+ }
125
+
126
+ export interface ChainLadderResult {
127
+ method: "chainLadder";
128
+ basis: TriangleKind;
129
+ /** cdfs[j] = cumulative factor from ages[j] to ultimate (last = tail factor). */
130
+ cdfs: number[];
131
+ percentDeveloped: number[];
132
+ rows: ChainLadderRow[];
133
+ totals: { latest: number; ultimate: number; unpaid: number };
134
+ warnings: string[];
135
+ }
136
+
137
+ export interface BornhuetterFergusonRow {
138
+ origin: string;
139
+ latestValue: number;
140
+ cdf: number;
141
+ /** A-priori expected loss ratio applied to the exposure base. */
142
+ aprioriLossRatio: number;
143
+ earnedPremium: number;
144
+ expectedUltimate: number;
145
+ expectedUnreported: number;
146
+ ultimate: number;
147
+ unpaid: number;
148
+ }
149
+
150
+ export interface BornhuetterFergusonResult {
151
+ method: "bornhuetterFerguson";
152
+ basis: TriangleKind;
153
+ rows: BornhuetterFergusonRow[];
154
+ totals: { latest: number; ultimate: number; unpaid: number };
155
+ warnings: string[];
156
+ }
157
+
158
+ export type TailMethod = "exponentialDecay" | "inversePower";
159
+
160
+ export interface TailFit {
161
+ method: TailMethod;
162
+ /** ln(f-1) = intercept + slope * x, x = period index (exp) or ln(index) (power). */
163
+ intercept: number;
164
+ slope: number;
165
+ rSquared: number;
166
+ nPoints: number;
167
+ /** Individual extrapolated age-to-age factors beyond the last observed age. */
168
+ extrapolatedFactors: number[];
169
+ tailFactor: number;
170
+ valid: boolean;
171
+ warnings: string[];
172
+ }
173
+
174
+ export interface MackRow {
175
+ origin: string;
176
+ latest: number;
177
+ ultimate: number;
178
+ reserve: number;
179
+ standardError: number;
180
+ /** standardError / reserve; null when reserve is 0. */
181
+ cv: number | null;
182
+ }
183
+
184
+ export interface MackResult {
185
+ method: "mack";
186
+ /** The projection factors: selected LDFs when supplied, else volume-weighted. */
187
+ developmentFactors: number[];
188
+ sigmaSquared: number[];
189
+ /** Tail factor the projection used (1 = none). */
190
+ tailFactor?: number;
191
+ /** Extrapolated sigma^2 for the tail step; present only when a tail was applied. */
192
+ sigmaSquaredTail?: number;
193
+ rows: MackRow[];
194
+ totals: {
195
+ latest: number;
196
+ ultimate: number;
197
+ reserve: number;
198
+ standardError: number;
199
+ cv: number | null;
200
+ };
201
+ warnings: string[];
202
+ }
203
+
204
+ export interface MerzWuthrichRow {
205
+ origin: string;
206
+ /** Chain ladder reserve at time I (ultimate minus the latest diagonal). */
207
+ reserve: number;
208
+ /**
209
+ * sqrt of the one-year CDR msep (Merz-Wuthrich 2008, eq. 3.17): the
210
+ * prediction uncertainty of 0 for next year's observable claims
211
+ * development result - the Solvency II / SST one-year reserve risk.
212
+ */
213
+ cdrMsepRoot: number;
214
+ /** sqrt of Mack's full-runoff msep for the same origin (ultimate view). */
215
+ mackMsepRoot: number;
216
+ /** cdrMsepRoot / mackMsepRoot; null when the Mack msep is 0. */
217
+ oneYearRatio: number | null;
218
+ }
219
+
220
+ export interface MerzWuthrichResult {
221
+ method: "merzWuthrich";
222
+ /** Volume-weighted development factors fhat_j estimated at time I. */
223
+ developmentFactors: number[];
224
+ /** sigma^2_j estimates; the final column uses Mack's extrapolation (4.1). */
225
+ sigmaSquared: number[];
226
+ rows: MerzWuthrichRow[];
227
+ totals: {
228
+ reserve: number;
229
+ /** Aggregate one-year msep root per eq. (3.18), cross terms included. */
230
+ cdrMsepRoot: number;
231
+ /** Mack's total full-runoff msep root, cross terms included. */
232
+ mackMsepRoot: number;
233
+ /** totals.cdrMsepRoot / totals.mackMsepRoot; null when the Mack total is 0. */
234
+ oneYearRatio: number | null;
235
+ };
236
+ warnings: string[];
237
+ }
238
+
239
+ export interface BerquistCaseAdequacyResult {
240
+ /** Average open case reserve per open claim, by cell. */
241
+ averageCaseReserves: (number | null)[][];
242
+ /** Annual severity trend used to restate historical average case reserves. */
243
+ severityTrend: number;
244
+ /** Whether the trend was fitted from the data or supplied by the user. */
245
+ trendSource: "fitted" | "user";
246
+ restatedAverageCaseReserves: (number | null)[][];
247
+ /** paid + restated average case reserve x open counts. */
248
+ adjustedIncurred: Triangle;
249
+ warnings: string[];
250
+ }
251
+
252
+ export interface BerquistSettlementResult {
253
+ /** disposal[i][j] = closed counts / ultimate counts for origin i. */
254
+ disposalRates: (number | null)[][];
255
+ /** Selected disposal rate per age (latest diagonal). */
256
+ selectedDisposalRates: (number | null)[];
257
+ ultimateCounts: number[];
258
+ adjustedClosedCounts: (number | null)[][];
259
+ interpolation: "exponential" | "linear";
260
+ adjustedPaid: Triangle;
261
+ warnings: string[];
262
+ }
263
+
264
+ export interface CalendarYearDiagnostic {
265
+ /** One entry per calendar-period diagonal that has testable factors. */
266
+ diagonals: {
267
+ label: string;
268
+ countLarge: number;
269
+ countSmall: number;
270
+ z: number;
271
+ expectedZ: number;
272
+ varianceZ: number;
273
+ }[];
274
+ totalZ: number;
275
+ expectedTotalZ: number;
276
+ varianceTotalZ: number;
277
+ /** Total Z outside the 95% confidence range indicates calendar-year effects. */
278
+ significant: boolean;
279
+ confidenceInterval: [number, number];
280
+ }
281
+
282
+ export interface DiagnosticsResult {
283
+ paidToIncurredRatios: (number | null)[][];
284
+ averageCaseReserves: (number | null)[][];
285
+ /** closed / reported counts by cell. */
286
+ closureRates: (number | null)[][];
287
+ calendarYearTest: CalendarYearDiagnostic | null;
288
+ /** Human-readable findings an actuary would care about. */
289
+ findings: DiagnosticFinding[];
290
+ }
291
+
292
+ export interface DiagnosticFinding {
293
+ severity: "info" | "warning" | "critical";
294
+ code: string;
295
+ message: string;
296
+ }
297
+
298
+ /**
299
+ * Every machine-readable code a ReservingError can carry. This registry is
300
+ * public contract: consumers may switch exhaustively on ReservingErrorCode,
301
+ * and test/registry.test.ts enforces that the list matches every constructor
302
+ * site in source. Add the code here when introducing a new throw.
303
+ */
304
+ export const RESERVING_ERROR_CODES = [
305
+ "BAD_ADJ",
306
+ "BAD_CAP",
307
+ "BAD_CASHFLOWS",
308
+ "BAD_CDF",
309
+ "BAD_COUNTS",
310
+ "BAD_DATE",
311
+ "BAD_ELR",
312
+ "BAD_FIT",
313
+ "BAD_INTERCHANGE",
314
+ "BAD_LIMIT",
315
+ "BAD_LOSSES",
316
+ "BAD_MARGIN",
317
+ "BAD_ORIGIN",
318
+ "BAD_PERCENTILE",
319
+ "BAD_PREMIUM",
320
+ "BAD_RATE",
321
+ "BAD_RATE_CHANGE",
322
+ "BAD_RATIO",
323
+ "BAD_SEED",
324
+ "BAD_SHAPE",
325
+ "BAD_TABLE",
326
+ "BAD_TAIL",
327
+ "BAD_TREND",
328
+ "BAD_WEIGHTS",
329
+ "INCOHERENT_SELECTION",
330
+ "INFINITE_MEAN",
331
+ "NO_APRIORI",
332
+ "NO_BF_ROWS",
333
+ "NO_CLAIMS",
334
+ "NO_DATA",
335
+ "NO_DEVELOPMENT",
336
+ "NO_FACTOR",
337
+ "NO_PROVENANCE",
338
+ "NO_SELECTIONS",
339
+ "SELECTION_SHAPE",
340
+ "SHAPE",
341
+ "TABLE_RANGE",
342
+ "TOO_SMALL",
343
+ "UNSUPPORTED_VALUE",
344
+ "UNSUPPORTED_VERSION",
345
+ ] as const;
346
+
347
+ export type ReservingErrorCode = (typeof RESERVING_ERROR_CODES)[number];
348
+
349
+ /** Thrown for invalid analysis input (all-missing selections, shape mismatches). */
350
+ export class ReservingError extends Error {
351
+ readonly code: ReservingErrorCode;
352
+ constructor(code: ReservingErrorCode, message: string) {
353
+ super(message);
354
+ this.name = "ReservingError";
355
+ this.code = code;
356
+ }
357
+ }