@actuarial-ts/core 0.2.0 → 0.4.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 +115 -4
- package/dist/berquist.d.ts.map +1 -1
- package/dist/berquist.js +4 -12
- package/dist/berquist.js.map +1 -1
- package/dist/caseOutstanding.d.ts.map +1 -1
- package/dist/caseOutstanding.js +2 -9
- package/dist/caseOutstanding.js.map +1 -1
- package/dist/casualtyDiagnostics.d.ts +53 -0
- package/dist/casualtyDiagnostics.d.ts.map +1 -0
- package/dist/casualtyDiagnostics.js +122 -0
- package/dist/casualtyDiagnostics.js.map +1 -0
- package/dist/fisherLange.d.ts.map +1 -1
- package/dist/fisherLange.js +2 -9
- package/dist/fisherLange.js.map +1 -1
- package/dist/freqSev.d.ts.map +1 -1
- package/dist/freqSev.js +3 -10
- package/dist/freqSev.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +58 -18
- package/dist/mack.js.map +1 -1
- package/dist/metricDiagnostics.d.ts +212 -0
- package/dist/metricDiagnostics.d.ts.map +1 -0
- package/dist/metricDiagnostics.js +627 -0
- package/dist/metricDiagnostics.js.map +1 -0
- package/dist/munichChainLadder.d.ts.map +1 -1
- package/dist/munichChainLadder.js +2 -7
- package/dist/munichChainLadder.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/dist/periods.d.ts +46 -0
- package/dist/periods.d.ts.map +1 -0
- package/dist/periods.js +121 -0
- package/dist/periods.js.map +1 -0
- package/dist/util.d.ts +8 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +15 -0
- package/dist/util.js.map +1 -1
- package/package.json +3 -1
- package/src/benktander.ts +90 -0
- package/src/berquist.ts +338 -0
- package/src/bf.ts +129 -0
- package/src/canonical.ts +122 -0
- package/src/capping.ts +295 -0
- package/src/caseOutstanding.ts +268 -0
- package/src/casualtyDiagnostics.ts +202 -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 +372 -0
- package/src/freqSev.ts +148 -0
- package/src/ilf.ts +567 -0
- package/src/index.ts +32 -0
- package/src/mack.ts +329 -0
- package/src/merzWuthrich.ts +147 -0
- package/src/metricDiagnostics.ts +876 -0
- package/src/munichChainLadder.ts +392 -0
- package/src/odpBootstrap.ts +337 -0
- package/src/onlevel.ts +155 -0
- package/src/periods.ts +177 -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 +88 -0
package/src/triangle.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClaimSnapshot,
|
|
3
|
+
OriginCadence,
|
|
4
|
+
Triangle,
|
|
5
|
+
TriangleKind,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
import { ReservingError } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds development triangles from claim-level evaluation snapshots.
|
|
11
|
+
*
|
|
12
|
+
* Conventions:
|
|
13
|
+
* - A claim belongs to the origin period containing its accident date.
|
|
14
|
+
* - Development age is measured in months from the start of the origin
|
|
15
|
+
* period; the age-m evaluation date is the last day of month (start + m - 1).
|
|
16
|
+
* - A cell is observable only when its evaluation date is on or before the
|
|
17
|
+
* as-of date; unobservable cells are null.
|
|
18
|
+
* - A claim's state at an evaluation date is the latest snapshot on or
|
|
19
|
+
* before that date (step function). A reported claim with no snapshot yet
|
|
20
|
+
* counts as reported/open with zero paid and zero case.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface ParsedDate {
|
|
24
|
+
y: number;
|
|
25
|
+
m: number; // 1-12
|
|
26
|
+
d: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseISO(date: string): ParsedDate {
|
|
30
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
|
|
31
|
+
if (!match) {
|
|
32
|
+
throw new ReservingError("BAD_DATE", `Invalid ISO date: "${date}"`);
|
|
33
|
+
}
|
|
34
|
+
const y = Number(match[1]);
|
|
35
|
+
const m = Number(match[2]);
|
|
36
|
+
const d = Number(match[3]);
|
|
37
|
+
if (m < 1 || m > 12 || d < 1 || d > 31) {
|
|
38
|
+
throw new ReservingError("BAD_DATE", `Invalid ISO date: "${date}"`);
|
|
39
|
+
}
|
|
40
|
+
return { y, m, d };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function monthIndex(p: ParsedDate): number {
|
|
44
|
+
return p.y * 12 + (p.m - 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function daysInMonth(y: number, m: number): number {
|
|
48
|
+
return new Date(Date.UTC(y, m, 0)).getUTCDate();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** ISO date of the last day of the month holding this month index. */
|
|
52
|
+
function endOfMonthISO(mIdx: number): string {
|
|
53
|
+
const y = Math.floor(mIdx / 12);
|
|
54
|
+
const m = (mIdx % 12) + 1;
|
|
55
|
+
const d = daysInMonth(y, m);
|
|
56
|
+
return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function periodIndexOf(p: ParsedDate, cadence: OriginCadence): number {
|
|
60
|
+
return cadence === "annual" ? p.y : p.y * 4 + Math.floor((p.m - 1) / 3);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function periodLabel(index: number, cadence: OriginCadence): string {
|
|
64
|
+
if (cadence === "annual") return String(index);
|
|
65
|
+
const y = Math.floor(index / 4);
|
|
66
|
+
const q = (index % 4) + 1;
|
|
67
|
+
return `${y}Q${q}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Month index of the first month of an origin period. */
|
|
71
|
+
function periodStartMonth(index: number, cadence: OriginCadence): number {
|
|
72
|
+
return cadence === "annual" ? index * 12 : index * 3;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BuildTrianglesOptions {
|
|
76
|
+
cadence: OriginCadence;
|
|
77
|
+
/** ISO evaluation date of the analysis (the latest diagonal). */
|
|
78
|
+
asOfDate: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TriangleSet {
|
|
82
|
+
paid: Triangle;
|
|
83
|
+
incurred: Triangle;
|
|
84
|
+
caseReserve: Triangle;
|
|
85
|
+
reportedCount: Triangle;
|
|
86
|
+
openCount: Triangle;
|
|
87
|
+
closedCount: Triangle;
|
|
88
|
+
closedWithPayCount: Triangle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface ClaimTimeline {
|
|
92
|
+
originIdx: number;
|
|
93
|
+
reportISO: string;
|
|
94
|
+
/** Snapshots sorted ascending by evaluation date. */
|
|
95
|
+
snapshots: ClaimSnapshot[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildTriangles(
|
|
99
|
+
claims: ClaimSnapshot[],
|
|
100
|
+
options: BuildTrianglesOptions,
|
|
101
|
+
): TriangleSet {
|
|
102
|
+
const { cadence, asOfDate } = options;
|
|
103
|
+
if (claims.length === 0) {
|
|
104
|
+
throw new ReservingError("NO_CLAIMS", "Cannot build triangles from an empty loss run");
|
|
105
|
+
}
|
|
106
|
+
const asOf = parseISO(asOfDate);
|
|
107
|
+
const asOfMonth = monthIndex(asOf);
|
|
108
|
+
const asOfIsMonthEnd = asOf.d === daysInMonth(asOf.y, asOf.m);
|
|
109
|
+
// The latest complete evaluation month.
|
|
110
|
+
const lastCompleteMonth = asOfIsMonthEnd ? asOfMonth : asOfMonth - 1;
|
|
111
|
+
const cadenceMonths = cadence === "annual" ? 12 : 3;
|
|
112
|
+
|
|
113
|
+
// Group snapshots into claim timelines and find the origin period range.
|
|
114
|
+
const byClaim = new Map<string, ClaimTimeline>();
|
|
115
|
+
let minPeriod = Infinity;
|
|
116
|
+
let maxPeriod = -Infinity;
|
|
117
|
+
for (const snap of claims) {
|
|
118
|
+
const accident = parseISO(snap.accidentDate);
|
|
119
|
+
if (snap.evaluationDate > asOfDate) continue; // beyond the analysis date
|
|
120
|
+
const period = periodIndexOf(accident, cadence);
|
|
121
|
+
minPeriod = Math.min(minPeriod, period);
|
|
122
|
+
maxPeriod = Math.max(maxPeriod, period);
|
|
123
|
+
let timeline = byClaim.get(snap.claimId);
|
|
124
|
+
if (!timeline) {
|
|
125
|
+
timeline = { originIdx: period, reportISO: snap.reportDate, snapshots: [] };
|
|
126
|
+
byClaim.set(snap.claimId, timeline);
|
|
127
|
+
}
|
|
128
|
+
timeline.snapshots.push(snap);
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isFinite(minPeriod)) {
|
|
131
|
+
throw new ReservingError(
|
|
132
|
+
"NO_CLAIMS",
|
|
133
|
+
"No claim snapshots fall on or before the as-of date",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
for (const timeline of byClaim.values()) {
|
|
137
|
+
timeline.snapshots.sort((a, b) =>
|
|
138
|
+
a.evaluationDate < b.evaluationDate ? -1 : a.evaluationDate > b.evaluationDate ? 1 : 0,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const nOrigins = maxPeriod - minPeriod + 1;
|
|
143
|
+
const origins: string[] = [];
|
|
144
|
+
for (let p = minPeriod; p <= maxPeriod; p++) origins.push(periodLabel(p, cadence));
|
|
145
|
+
|
|
146
|
+
// Ages available to the oldest origin period determine the column count.
|
|
147
|
+
const oldestStart = periodStartMonth(minPeriod, cadence);
|
|
148
|
+
const maxAge = lastCompleteMonth - oldestStart + 1;
|
|
149
|
+
const nAges = Math.floor(maxAge / cadenceMonths);
|
|
150
|
+
if (nAges < 1) {
|
|
151
|
+
throw new ReservingError(
|
|
152
|
+
"NO_DEVELOPMENT",
|
|
153
|
+
"The as-of date precedes the first complete development age",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const ages: number[] = [];
|
|
157
|
+
for (let j = 1; j <= nAges; j++) ages.push(j * cadenceMonths);
|
|
158
|
+
|
|
159
|
+
const mk = (kind: TriangleKind): Triangle => ({
|
|
160
|
+
kind,
|
|
161
|
+
origins: [...origins],
|
|
162
|
+
ages: [...ages],
|
|
163
|
+
values: Array.from({ length: nOrigins }, (_, i) =>
|
|
164
|
+
ages.map((age) => {
|
|
165
|
+
const evalMonth = periodStartMonth(minPeriod + i, cadence) + age - 1;
|
|
166
|
+
return evalMonth <= lastCompleteMonth ? 0 : null;
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const set: TriangleSet = {
|
|
172
|
+
paid: mk("paid"),
|
|
173
|
+
incurred: mk("incurred"),
|
|
174
|
+
caseReserve: mk("caseReserve"),
|
|
175
|
+
reportedCount: mk("reportedCount"),
|
|
176
|
+
openCount: mk("openCount"),
|
|
177
|
+
closedCount: mk("closedCount"),
|
|
178
|
+
closedWithPayCount: mk("closedWithPayCount"),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const add = (tri: Triangle, i: number, j: number, v: number) => {
|
|
182
|
+
const cell = tri.values[i]![j];
|
|
183
|
+
if (cell !== null && cell !== undefined) tri.values[i]![j] = cell + v;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const timeline of byClaim.values()) {
|
|
187
|
+
const i = timeline.originIdx - minPeriod;
|
|
188
|
+
const originStart = periodStartMonth(timeline.originIdx, cadence);
|
|
189
|
+
for (let j = 0; j < nAges; j++) {
|
|
190
|
+
const evalMonth = originStart + ages[j]! - 1;
|
|
191
|
+
if (evalMonth > lastCompleteMonth) break; // this and later cells are null
|
|
192
|
+
const evalISO = endOfMonthISO(evalMonth);
|
|
193
|
+
if (timeline.reportISO > evalISO) continue; // not yet reported
|
|
194
|
+
// Latest snapshot on or before the cell's evaluation date.
|
|
195
|
+
let state: ClaimSnapshot | null = null;
|
|
196
|
+
for (const snap of timeline.snapshots) {
|
|
197
|
+
if (snap.evaluationDate <= evalISO) state = snap;
|
|
198
|
+
else break;
|
|
199
|
+
}
|
|
200
|
+
const paid = state?.paidToDate ?? 0;
|
|
201
|
+
const caseReserve = state?.status === "open" ? (state?.caseReserve ?? 0) : 0;
|
|
202
|
+
const isClosed = state?.status === "closed";
|
|
203
|
+
add(set.reportedCount, i, j, 1);
|
|
204
|
+
add(set.paid, i, j, paid);
|
|
205
|
+
add(set.caseReserve, i, j, caseReserve);
|
|
206
|
+
add(set.incurred, i, j, paid + caseReserve);
|
|
207
|
+
if (isClosed) {
|
|
208
|
+
add(set.closedCount, i, j, 1);
|
|
209
|
+
if (paid > 0) add(set.closedWithPayCount, i, j, 1);
|
|
210
|
+
} else {
|
|
211
|
+
add(set.openCount, i, j, 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return set;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Constructs a triangle directly from a grid of values (import path). */
|
|
220
|
+
export function triangleFromGrid(
|
|
221
|
+
kind: TriangleKind,
|
|
222
|
+
origins: string[],
|
|
223
|
+
ages: number[],
|
|
224
|
+
values: (number | null)[][],
|
|
225
|
+
): Triangle {
|
|
226
|
+
if (values.length !== origins.length) {
|
|
227
|
+
throw new ReservingError("SHAPE", "Row count does not match origin count");
|
|
228
|
+
}
|
|
229
|
+
for (const row of values) {
|
|
230
|
+
if (row.length !== ages.length) {
|
|
231
|
+
throw new ReservingError("SHAPE", "Column count does not match age count");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { kind, origins: [...origins], ages: [...ages], values: values.map((r) => [...r]) };
|
|
235
|
+
}
|
|
@@ -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
|
+
}
|