@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.
- package/README.md +11 -4
- package/dist/canonical.d.ts +19 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +118 -0
- package/dist/canonical.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -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/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/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/package.json +4 -2
- 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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { BornhuetterFergusonResult, ChainLadderResult } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Benktander-Hovinen method (the "iterated Bornhuetter-Ferguson").
|
|
6
|
+
*
|
|
7
|
+
* Ground truth (Mack 2000, "Credible Claims Reserves: The Benktander
|
|
8
|
+
* Method", ASTIN Bulletin 30(2)):
|
|
9
|
+
* - With q = 1 - 1/CDF (the expected unreported/unpaid fraction) and C the
|
|
10
|
+
* losses to date: U_GB = C + q x U_BF — Bornhuetter-Ferguson applied once
|
|
11
|
+
* more, with the BF ultimate as the a-priori.
|
|
12
|
+
* - Equivalently a credibility mixture U_GB = (1-q) x U_CL + q x U_BF with
|
|
13
|
+
* credibility Z = 1-q on the chain ladder: mature periods lean on CL,
|
|
14
|
+
* green periods lean on the a-priori — automatically.
|
|
15
|
+
* - Mack (2000) shows U_GB has a smaller mean squared error than both CL and
|
|
16
|
+
* BF over a wide parameter range; it is the standard "use both" answer.
|
|
17
|
+
*
|
|
18
|
+
* Rows are the BF result's rows (BF excludes origins with no usable
|
|
19
|
+
* premium; those stay excluded here). CDFs below 1 (incurred bases with
|
|
20
|
+
* expected downward development) make q negative — the estimator still
|
|
21
|
+
* evaluates, but it is an extrapolation past the chain ladder rather than a
|
|
22
|
+
* mixture, and the result says so in warnings.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface BenktanderRow {
|
|
26
|
+
origin: string;
|
|
27
|
+
latestValue: number;
|
|
28
|
+
cdf: number;
|
|
29
|
+
/** Credibility on the chain ladder: Z = 1 - q = 1/CDF. */
|
|
30
|
+
credibilityZ: number;
|
|
31
|
+
clUltimate: number;
|
|
32
|
+
bfUltimate: number;
|
|
33
|
+
ultimate: number;
|
|
34
|
+
unpaid: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BenktanderResult {
|
|
38
|
+
method: "benktander";
|
|
39
|
+
basis: BornhuetterFergusonResult["basis"];
|
|
40
|
+
rows: BenktanderRow[];
|
|
41
|
+
totals: { latest: number; ultimate: number; unpaid: number };
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function runBenktander(
|
|
46
|
+
chainLadder: ChainLadderResult,
|
|
47
|
+
bf: BornhuetterFergusonResult,
|
|
48
|
+
): BenktanderResult {
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
const clByOrigin = new Map(chainLadder.rows.map((r) => [r.origin, r]));
|
|
51
|
+
|
|
52
|
+
const rows: BenktanderRow[] = bf.rows.map((bfRow) => {
|
|
53
|
+
const cl = clByOrigin.get(bfRow.origin);
|
|
54
|
+
if (!cl) {
|
|
55
|
+
throw new ReservingError(
|
|
56
|
+
"SHAPE",
|
|
57
|
+
`Origin ${bfRow.origin} is in the Bornhuetter-Ferguson result but missing from the chain ladder result; both must come from the same run`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const q = 1 - 1 / bfRow.cdf;
|
|
61
|
+
if (q < 0) {
|
|
62
|
+
warnings.push(
|
|
63
|
+
`Origin ${bfRow.origin}: CDF ${bfRow.cdf.toFixed(3)} is below 1, so Benktander extrapolates past the chain ladder rather than blending (expected downward development)`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const ultimate = bfRow.latestValue + q * bfRow.ultimate;
|
|
67
|
+
return {
|
|
68
|
+
origin: bfRow.origin,
|
|
69
|
+
latestValue: bfRow.latestValue,
|
|
70
|
+
cdf: bfRow.cdf,
|
|
71
|
+
credibilityZ: 1 - q,
|
|
72
|
+
clUltimate: cl.ultimate,
|
|
73
|
+
bfUltimate: bfRow.ultimate,
|
|
74
|
+
ultimate,
|
|
75
|
+
unpaid: ultimate - bfRow.latestValue,
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
method: "benktander",
|
|
81
|
+
basis: bf.basis,
|
|
82
|
+
rows,
|
|
83
|
+
totals: {
|
|
84
|
+
latest: rows.reduce((a, r) => a + r.latestValue, 0),
|
|
85
|
+
ultimate: rows.reduce((a, r) => a + r.ultimate, 0),
|
|
86
|
+
unpaid: rows.reduce((a, r) => a + r.unpaid, 0),
|
|
87
|
+
},
|
|
88
|
+
warnings,
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/berquist.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BerquistCaseAdequacyResult,
|
|
3
|
+
BerquistSettlementResult,
|
|
4
|
+
Triangle,
|
|
5
|
+
} from "./types.js";
|
|
6
|
+
import { ReservingError } from "./types.js";
|
|
7
|
+
import { isNum, lastObservedIndex, ols, safeRatio } from "./util.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Berquist-Sherman adjustments (Berquist & Sherman 1977; Friedland ch. 13).
|
|
11
|
+
*
|
|
12
|
+
* Case-reserve adequacy: restate historical average case reserves (case
|
|
13
|
+
* reserves / open counts, by cell) to the current adequacy level by
|
|
14
|
+
* de-trending the latest diagonal backwards at an annual severity trend,
|
|
15
|
+
* then rebuild adjusted incurred = paid + restated average case x open counts.
|
|
16
|
+
*
|
|
17
|
+
* Settlement-rate: compute disposal rates (closed counts / ultimate counts),
|
|
18
|
+
* take the current settlement pattern from the latest diagonal, and restate
|
|
19
|
+
* historical paid losses by interpolating paid at the disposal-rate-equivalent
|
|
20
|
+
* closed-count level -- the textbook interpolation on (closed count, paid)
|
|
21
|
+
* points within each origin row, not a scalar ratio shortcut.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function assertSameShape(a: Triangle, b: Triangle, what: string): void {
|
|
25
|
+
if (
|
|
26
|
+
a.origins.length !== b.origins.length ||
|
|
27
|
+
a.ages.length !== b.ages.length ||
|
|
28
|
+
a.origins.some((o, i) => o !== b.origins[i]) ||
|
|
29
|
+
a.ages.some((g, j) => g !== b.ages[j])
|
|
30
|
+
) {
|
|
31
|
+
throw new ReservingError("SHAPE", `${what} triangles must share origins and ages`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Last observed row index in a column; -1 when the column is empty. */
|
|
36
|
+
function lastObservedRowInColumn(tri: Triangle, j: number): number {
|
|
37
|
+
for (let i = tri.origins.length - 1; i >= 0; i--) {
|
|
38
|
+
if (isNum(tri.values[i]![j] ?? null)) return i;
|
|
39
|
+
}
|
|
40
|
+
return -1;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CaseAdequacyOptions {
|
|
44
|
+
/** Annual severity trend override (e.g. 0.15 for +15%/yr). Fitted when omitted. */
|
|
45
|
+
severityTrend?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function berquistCaseAdequacy(
|
|
49
|
+
paid: Triangle,
|
|
50
|
+
incurred: Triangle,
|
|
51
|
+
openCounts: Triangle,
|
|
52
|
+
options: CaseAdequacyOptions = {},
|
|
53
|
+
): BerquistCaseAdequacyResult {
|
|
54
|
+
assertSameShape(paid, incurred, "Paid and incurred");
|
|
55
|
+
assertSameShape(paid, openCounts, "Paid and open-count");
|
|
56
|
+
const nOrigins = paid.origins.length;
|
|
57
|
+
const nAges = paid.ages.length;
|
|
58
|
+
const warnings: string[] = [];
|
|
59
|
+
const periodYears = (paid.ages[0] ?? 12) / 12;
|
|
60
|
+
|
|
61
|
+
// Average open case reserve per open claim, by cell.
|
|
62
|
+
const averageCaseReserves: (number | null)[][] = [];
|
|
63
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
64
|
+
const row: (number | null)[] = [];
|
|
65
|
+
for (let j = 0; j < nAges; j++) {
|
|
66
|
+
const inc = incurred.values[i]![j] ?? null;
|
|
67
|
+
const pd = paid.values[i]![j] ?? null;
|
|
68
|
+
const open = openCounts.values[i]![j] ?? null;
|
|
69
|
+
const caseAmt = isNum(inc) && isNum(pd) ? inc - pd : null;
|
|
70
|
+
row.push(safeRatio(caseAmt, open));
|
|
71
|
+
}
|
|
72
|
+
averageCaseReserves.push(row);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Severity trend: user override, else fitted from ln(average case) regressed
|
|
76
|
+
// on origin index within each column, combined as a point-weighted average.
|
|
77
|
+
let severityTrend: number;
|
|
78
|
+
let trendSource: "fitted" | "user";
|
|
79
|
+
if (options.severityTrend !== undefined) {
|
|
80
|
+
severityTrend = options.severityTrend;
|
|
81
|
+
trendSource = "user";
|
|
82
|
+
} else {
|
|
83
|
+
let weighted = 0;
|
|
84
|
+
let weight = 0;
|
|
85
|
+
for (let j = 0; j < nAges; j++) {
|
|
86
|
+
const xs: number[] = [];
|
|
87
|
+
const ys: number[] = [];
|
|
88
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
89
|
+
const v = averageCaseReserves[i]![j] ?? null;
|
|
90
|
+
if (isNum(v) && v > 0) {
|
|
91
|
+
xs.push(i);
|
|
92
|
+
ys.push(Math.log(v));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (xs.length >= 3) {
|
|
96
|
+
const fit = ols(xs, ys);
|
|
97
|
+
if (fit) {
|
|
98
|
+
const annualTrend = Math.exp(fit.slope / periodYears) - 1;
|
|
99
|
+
weighted += annualTrend * fit.n;
|
|
100
|
+
weight += fit.n;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (weight > 0) {
|
|
105
|
+
severityTrend = weighted / weight;
|
|
106
|
+
trendSource = "fitted";
|
|
107
|
+
} else {
|
|
108
|
+
severityTrend = 0;
|
|
109
|
+
trendSource = "fitted";
|
|
110
|
+
warnings.push(
|
|
111
|
+
"Could not fit a severity trend from the data (too few open-claim cells); used 0% -- supply a trend explicitly",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (severityTrend <= -1) {
|
|
116
|
+
throw new ReservingError("BAD_TREND", "Severity trend must be greater than -100%");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Restate: de-trend each column's latest-diagonal average case backwards.
|
|
120
|
+
const restated: (number | null)[][] = averageCaseReserves.map((r) => r.map(() => null));
|
|
121
|
+
for (let j = 0; j < nAges; j++) {
|
|
122
|
+
const diagRow = lastObservedRowInColumn(openCounts, j);
|
|
123
|
+
if (diagRow < 0) continue;
|
|
124
|
+
const diagAvg = averageCaseReserves[diagRow]![j] ?? null;
|
|
125
|
+
for (let i = 0; i <= diagRow; i++) {
|
|
126
|
+
if (!isNum(openCounts.values[i]![j] ?? null)) continue;
|
|
127
|
+
if (!isNum(diagAvg)) {
|
|
128
|
+
// No open claims on the diagonal for this maturity; keep the actual value.
|
|
129
|
+
restated[i]![j] = averageCaseReserves[i]![j] ?? null;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const yearsBack = (diagRow - i) * periodYears;
|
|
133
|
+
restated[i]![j] = diagAvg / Math.pow(1 + severityTrend, yearsBack);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Adjusted incurred = paid + restated average case x open counts.
|
|
138
|
+
const adjustedValues: (number | null)[][] = [];
|
|
139
|
+
let fallbackCells = 0;
|
|
140
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
141
|
+
const row: (number | null)[] = [];
|
|
142
|
+
for (let j = 0; j < nAges; j++) {
|
|
143
|
+
const pd = paid.values[i]![j] ?? null;
|
|
144
|
+
const open = openCounts.values[i]![j] ?? null;
|
|
145
|
+
if (!isNum(pd) || !isNum(open)) {
|
|
146
|
+
row.push(null);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (open === 0) {
|
|
150
|
+
row.push(pd);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const avg = restated[i]![j] ?? null;
|
|
154
|
+
if (!isNum(avg)) {
|
|
155
|
+
row.push(incurred.values[i]![j] ?? null);
|
|
156
|
+
fallbackCells++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
row.push(pd + avg * open);
|
|
160
|
+
}
|
|
161
|
+
adjustedValues.push(row);
|
|
162
|
+
}
|
|
163
|
+
if (fallbackCells > 0) {
|
|
164
|
+
warnings.push(
|
|
165
|
+
`${fallbackCells} cell(s) kept their unadjusted incurred value because no diagonal average case reserve was available at that maturity`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
averageCaseReserves,
|
|
171
|
+
severityTrend,
|
|
172
|
+
trendSource,
|
|
173
|
+
restatedAverageCaseReserves: restated,
|
|
174
|
+
adjustedIncurred: {
|
|
175
|
+
kind: "incurred",
|
|
176
|
+
origins: [...paid.origins],
|
|
177
|
+
ages: [...paid.ages],
|
|
178
|
+
values: adjustedValues,
|
|
179
|
+
},
|
|
180
|
+
warnings,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface SettlementOptions {
|
|
185
|
+
/** Selected ultimate claim counts per origin (same order as triangle origins). */
|
|
186
|
+
ultimateCounts: number[];
|
|
187
|
+
/** Interpolation between (closed count, paid) points. Friedland uses exponential. */
|
|
188
|
+
interpolation?: "exponential" | "linear";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface Point {
|
|
192
|
+
x: number; // cumulative closed counts
|
|
193
|
+
y: number; // cumulative paid
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function interpolate(
|
|
197
|
+
points: Point[],
|
|
198
|
+
x: number,
|
|
199
|
+
mode: "exponential" | "linear",
|
|
200
|
+
warnings: string[],
|
|
201
|
+
cellLabel: string,
|
|
202
|
+
): number | null {
|
|
203
|
+
if (points.length === 0) return null;
|
|
204
|
+
// Exact hit keeps the actual value.
|
|
205
|
+
for (const p of points) {
|
|
206
|
+
if (Math.abs(p.x - x) < 1e-9) return p.y;
|
|
207
|
+
}
|
|
208
|
+
let lower: Point | null = null;
|
|
209
|
+
let upper: Point | null = null;
|
|
210
|
+
for (const p of points) {
|
|
211
|
+
if (p.x < x && (!lower || p.x > lower.x)) lower = p;
|
|
212
|
+
if (p.x > x && (!upper || p.x < upper.x)) upper = p;
|
|
213
|
+
}
|
|
214
|
+
if (lower && upper) {
|
|
215
|
+
return interpolateSegment(lower, upper, x, mode);
|
|
216
|
+
}
|
|
217
|
+
if (!lower && upper) {
|
|
218
|
+
// Below every observed point: anchor at the origin (0 closed, 0 paid).
|
|
219
|
+
return interpolateSegment({ x: 0, y: 0 }, upper, x, mode);
|
|
220
|
+
}
|
|
221
|
+
if (lower && !upper) {
|
|
222
|
+
// Above every observed point: extrapolate from the last segment.
|
|
223
|
+
const below = points.filter((p) => p.x < lower!.x);
|
|
224
|
+
const prev = below.length > 0 ? below[below.length - 1]! : { x: 0, y: 0 };
|
|
225
|
+
warnings.push(
|
|
226
|
+
`Adjusted closed counts at ${cellLabel} exceed every observed count in that origin row; paid was extrapolated beyond the data`,
|
|
227
|
+
);
|
|
228
|
+
return interpolateSegment(prev, lower, x, mode);
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function interpolateSegment(
|
|
234
|
+
p1: Point,
|
|
235
|
+
p2: Point,
|
|
236
|
+
x: number,
|
|
237
|
+
mode: "exponential" | "linear",
|
|
238
|
+
): number {
|
|
239
|
+
if (p2.x === p1.x) return p1.y;
|
|
240
|
+
if (mode === "exponential" && p1.y > 0 && p2.y > 0) {
|
|
241
|
+
// y = a * e^(b x) through both points (log-linear in paid).
|
|
242
|
+
const b = Math.log(p2.y / p1.y) / (p2.x - p1.x);
|
|
243
|
+
const a = p1.y * Math.exp(-b * p1.x);
|
|
244
|
+
return a * Math.exp(b * x);
|
|
245
|
+
}
|
|
246
|
+
// Linear (also the fallback when a zero paid value makes exponential undefined).
|
|
247
|
+
return p1.y + ((p2.y - p1.y) * (x - p1.x)) / (p2.x - p1.x);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function berquistSettlement(
|
|
251
|
+
paid: Triangle,
|
|
252
|
+
closedCounts: Triangle,
|
|
253
|
+
options: SettlementOptions,
|
|
254
|
+
): BerquistSettlementResult {
|
|
255
|
+
assertSameShape(paid, closedCounts, "Paid and closed-count");
|
|
256
|
+
const { ultimateCounts } = options;
|
|
257
|
+
const interpolation = options.interpolation ?? "exponential";
|
|
258
|
+
const nOrigins = paid.origins.length;
|
|
259
|
+
const nAges = paid.ages.length;
|
|
260
|
+
if (ultimateCounts.length !== nOrigins) {
|
|
261
|
+
throw new ReservingError(
|
|
262
|
+
"SHAPE",
|
|
263
|
+
`Expected ${nOrigins} ultimate claim counts (one per origin), got ${ultimateCounts.length}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const warnings: string[] = [];
|
|
267
|
+
|
|
268
|
+
// Disposal rates = closed counts / ultimate counts.
|
|
269
|
+
const disposalRates: (number | null)[][] = [];
|
|
270
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
271
|
+
const ult = ultimateCounts[i]!;
|
|
272
|
+
const row: (number | null)[] = [];
|
|
273
|
+
for (let j = 0; j < nAges; j++) {
|
|
274
|
+
row.push(safeRatio(closedCounts.values[i]![j] ?? null, ult > 0 ? ult : null));
|
|
275
|
+
}
|
|
276
|
+
disposalRates.push(row);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Current settlement pattern: disposal rates along the latest diagonal.
|
|
280
|
+
const selectedDisposalRates: (number | null)[] = [];
|
|
281
|
+
for (let j = 0; j < nAges; j++) {
|
|
282
|
+
const diagRow = lastObservedRowInColumn(closedCounts, j);
|
|
283
|
+
selectedDisposalRates.push(diagRow >= 0 ? (disposalRates[diagRow]![j] ?? null) : null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Restated closed counts at the current settlement pattern.
|
|
287
|
+
const adjustedClosedCounts: (number | null)[][] = [];
|
|
288
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
289
|
+
const ult = ultimateCounts[i]!;
|
|
290
|
+
const row: (number | null)[] = [];
|
|
291
|
+
for (let j = 0; j < nAges; j++) {
|
|
292
|
+
const observed = isNum(closedCounts.values[i]![j] ?? null);
|
|
293
|
+
const sel = selectedDisposalRates[j] ?? null;
|
|
294
|
+
row.push(observed && isNum(sel) && ult > 0 ? sel * ult : null);
|
|
295
|
+
}
|
|
296
|
+
adjustedClosedCounts.push(row);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Interpolate adjusted paid within each origin row on (closed, paid) points.
|
|
300
|
+
const adjustedValues: (number | null)[][] = [];
|
|
301
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
302
|
+
const points: Point[] = [];
|
|
303
|
+
for (let j = 0; j < nAges; j++) {
|
|
304
|
+
const c = closedCounts.values[i]![j] ?? null;
|
|
305
|
+
const p = paid.values[i]![j] ?? null;
|
|
306
|
+
if (isNum(c) && isNum(p)) points.push({ x: c, y: p });
|
|
307
|
+
}
|
|
308
|
+
points.sort((a, b) => a.x - b.x);
|
|
309
|
+
// Collapse duplicate closed counts, keeping the latest evaluation's paid
|
|
310
|
+
// (points sort stably by closed count, so for equal counts the later
|
|
311
|
+
// development age comes last; taking its value also handles the rare
|
|
312
|
+
// decreasing-paid case, e.g. recoveries).
|
|
313
|
+
const dedup: Point[] = [];
|
|
314
|
+
for (const p of points) {
|
|
315
|
+
const last = dedup[dedup.length - 1];
|
|
316
|
+
if (last && Math.abs(last.x - p.x) < 1e-9) last.y = p.y;
|
|
317
|
+
else dedup.push({ ...p });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const row: (number | null)[] = [];
|
|
321
|
+
for (let j = 0; j < nAges; j++) {
|
|
322
|
+
const target = adjustedClosedCounts[i]![j] ?? null;
|
|
323
|
+
const actualPaid = paid.values[i]![j] ?? null;
|
|
324
|
+
if (!isNum(target) || !isNum(actualPaid)) {
|
|
325
|
+
row.push(actualPaid);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const label = `${paid.origins[i]} @ ${paid.ages[j]}mo`;
|
|
329
|
+
const y = interpolate(dedup, target, interpolation, warnings, label);
|
|
330
|
+
row.push(isNum(y) ? Math.max(0, y) : actualPaid);
|
|
331
|
+
}
|
|
332
|
+
adjustedValues.push(row);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
disposalRates,
|
|
337
|
+
selectedDisposalRates,
|
|
338
|
+
ultimateCounts: [...ultimateCounts],
|
|
339
|
+
adjustedClosedCounts,
|
|
340
|
+
interpolation,
|
|
341
|
+
adjustedPaid: {
|
|
342
|
+
kind: "paid",
|
|
343
|
+
origins: [...paid.origins],
|
|
344
|
+
ages: [...paid.ages],
|
|
345
|
+
values: adjustedValues,
|
|
346
|
+
},
|
|
347
|
+
warnings,
|
|
348
|
+
};
|
|
349
|
+
}
|
package/src/bf.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BornhuetterFergusonResult,
|
|
3
|
+
BornhuetterFergusonRow,
|
|
4
|
+
ChainLadderResult,
|
|
5
|
+
ExposureRecord,
|
|
6
|
+
Triangle,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { ReservingError } from "./types.js";
|
|
9
|
+
import { isNum, lastObservedIndex } from "./util.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bornhuetter-Ferguson method.
|
|
13
|
+
*
|
|
14
|
+
* Ground truth:
|
|
15
|
+
* - Expected ultimate = a-priori loss ratio x earned premium.
|
|
16
|
+
* - BF ultimate = actual to date + expected ultimate x (1 - 1/CDF).
|
|
17
|
+
*
|
|
18
|
+
* A-priori selection: by default, derived from the chain ladder ultimates of
|
|
19
|
+
* mature origin periods (percent developed >= maturityThreshold), as the
|
|
20
|
+
* premium-weighted loss ratio across those periods. The user (or the advisor)
|
|
21
|
+
* can override with an explicit loss ratio, globally or per origin.
|
|
22
|
+
*/
|
|
23
|
+
export interface BfOptions {
|
|
24
|
+
/** Global a-priori loss ratio override. */
|
|
25
|
+
aprioriLossRatio?: number;
|
|
26
|
+
/** Per-origin a-priori overrides (take precedence over the global value). */
|
|
27
|
+
aprioriByOrigin?: Record<string, number>;
|
|
28
|
+
/** Percent-developed cutoff for "mature" periods used to derive the default a-priori. */
|
|
29
|
+
maturityThreshold?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runBornhuetterFerguson(
|
|
33
|
+
tri: Triangle,
|
|
34
|
+
chainLadder: ChainLadderResult,
|
|
35
|
+
exposures: ExposureRecord[],
|
|
36
|
+
options: BfOptions = {},
|
|
37
|
+
): BornhuetterFergusonResult {
|
|
38
|
+
const warnings: string[] = [];
|
|
39
|
+
const maturityThreshold = options.maturityThreshold ?? 0.9;
|
|
40
|
+
const premiumByOrigin = new Map(exposures.map((e) => [e.origin, e.earnedPremium]));
|
|
41
|
+
|
|
42
|
+
// Default a-priori: premium-weighted CL loss ratio across mature periods.
|
|
43
|
+
let derivedApriori: number | null = null;
|
|
44
|
+
{
|
|
45
|
+
let lossSum = 0;
|
|
46
|
+
let premSum = 0;
|
|
47
|
+
for (const row of chainLadder.rows) {
|
|
48
|
+
const prem = premiumByOrigin.get(row.origin);
|
|
49
|
+
if (!isNum(prem ?? null) || prem! <= 0) continue;
|
|
50
|
+
if (row.percentDeveloped >= maturityThreshold) {
|
|
51
|
+
lossSum += row.ultimate;
|
|
52
|
+
premSum += prem!;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (premSum > 0) derivedApriori = lossSum / premSum;
|
|
56
|
+
}
|
|
57
|
+
if (derivedApriori === null && options.aprioriLossRatio === undefined) {
|
|
58
|
+
// Fall back to all periods when nothing is mature enough.
|
|
59
|
+
let lossSum = 0;
|
|
60
|
+
let premSum = 0;
|
|
61
|
+
for (const row of chainLadder.rows) {
|
|
62
|
+
const prem = premiumByOrigin.get(row.origin);
|
|
63
|
+
if (!isNum(prem ?? null) || prem! <= 0) continue;
|
|
64
|
+
lossSum += row.ultimate;
|
|
65
|
+
premSum += prem!;
|
|
66
|
+
}
|
|
67
|
+
if (premSum > 0) {
|
|
68
|
+
derivedApriori = lossSum / premSum;
|
|
69
|
+
warnings.push(
|
|
70
|
+
"No origin period is mature enough to anchor the a-priori; derived it from all periods' chain ladder ultimates instead",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const clByOrigin = new Map(chainLadder.rows.map((r) => [r.origin, r]));
|
|
76
|
+
const rows: BornhuetterFergusonRow[] = [];
|
|
77
|
+
for (let i = 0; i < tri.origins.length; i++) {
|
|
78
|
+
const origin = tri.origins[i]!;
|
|
79
|
+
const cl = clByOrigin.get(origin);
|
|
80
|
+
if (!cl) continue;
|
|
81
|
+
const latestIdx = lastObservedIndex(tri.values[i]!);
|
|
82
|
+
const latestValue = latestIdx >= 0 ? tri.values[i]![latestIdx]! : 0;
|
|
83
|
+
const prem = premiumByOrigin.get(origin) ?? null;
|
|
84
|
+
if (!isNum(prem) || prem <= 0) {
|
|
85
|
+
warnings.push(`Origin ${origin} has no usable earned premium; excluded from BF`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const apriori =
|
|
89
|
+
options.aprioriByOrigin?.[origin] ?? options.aprioriLossRatio ?? derivedApriori;
|
|
90
|
+
if (!isNum(apriori ?? null)) {
|
|
91
|
+
throw new ReservingError(
|
|
92
|
+
"NO_APRIORI",
|
|
93
|
+
"Cannot derive an a-priori loss ratio (no exposure data matched the triangle origins) and no override was supplied",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const expectedUltimate = apriori! * prem;
|
|
97
|
+
const expectedUnreported = expectedUltimate * (1 - 1 / cl.cdf);
|
|
98
|
+
const ultimate = latestValue + expectedUnreported;
|
|
99
|
+
rows.push({
|
|
100
|
+
origin,
|
|
101
|
+
latestValue,
|
|
102
|
+
cdf: cl.cdf,
|
|
103
|
+
aprioriLossRatio: apriori!,
|
|
104
|
+
earnedPremium: prem,
|
|
105
|
+
expectedUltimate,
|
|
106
|
+
expectedUnreported,
|
|
107
|
+
ultimate,
|
|
108
|
+
unpaid: ultimate - latestValue,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (rows.length === 0) {
|
|
113
|
+
throw new ReservingError(
|
|
114
|
+
"NO_BF_ROWS",
|
|
115
|
+
"Bornhuetter-Ferguson produced no rows; check that exposure origins match the triangle origins",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const totals = rows.reduce(
|
|
120
|
+
(acc, r) => ({
|
|
121
|
+
latest: acc.latest + r.latestValue,
|
|
122
|
+
ultimate: acc.ultimate + r.ultimate,
|
|
123
|
+
unpaid: acc.unpaid + r.unpaid,
|
|
124
|
+
}),
|
|
125
|
+
{ latest: 0, ultimate: 0, unpaid: 0 },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return { method: "bornhuetterFerguson", basis: tri.kind, rows, totals, warnings };
|
|
129
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical JSON serialization and the FNV-1a integrity hash — the SDK's
|
|
5
|
+
* single equality oracle, relocated here from @actuarial-ts/compliance so
|
|
6
|
+
* the interchange layer can depend on it without a package cycle
|
|
7
|
+
* (compliance re-exports both names unchanged).
|
|
8
|
+
*
|
|
9
|
+
* Ground truth:
|
|
10
|
+
* - Object keys sort recursively (UTF-16 code-unit order), arrays keep
|
|
11
|
+
* order, no whitespace, numbers render via `String(n)` with -0
|
|
12
|
+
* normalized to "0", and anything JSON cannot faithfully represent
|
|
13
|
+
* (undefined, functions, NaN/Infinity, bigint, symbol, non-plain objects
|
|
14
|
+
* such as Date/Map/Set, circular references) THROWS with the offending
|
|
15
|
+
* path instead of being silently dropped or coerced the way
|
|
16
|
+
* JSON.stringify would.
|
|
17
|
+
* - RFC 8785 (JCS) conformance: for the plain-JSON value space this
|
|
18
|
+
* function accepts, the output IS JCS — ECMAScript `String(n)` is
|
|
19
|
+
* exactly the shortest-round-trip number serialization RFC 8785
|
|
20
|
+
* specifies, default `sort()` is the UTF-16 code-unit key order it
|
|
21
|
+
* requires, and `JSON.stringify` string escaping matches its minimal
|
|
22
|
+
* escaping rules. The committed vector suite
|
|
23
|
+
* (schema/interchange/1.0/jcs-vectors.json) pins this claim byte for
|
|
24
|
+
* byte, and every non-TS interchange adapter must reproduce the same
|
|
25
|
+
* vectors.
|
|
26
|
+
* - Timestamps are caller-supplied ISO strings; this module never reads a
|
|
27
|
+
* clock, so identical inputs yield byte-identical output.
|
|
28
|
+
* - Browser-safe: no node builtins (TextEncoder is a web-standard global).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
33
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
34
|
+
return proto === Object.prototype || proto === null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function canonicalize(value: unknown, path: string, seen: Set<object>): string {
|
|
38
|
+
if (value === null) return "null";
|
|
39
|
+
switch (typeof value) {
|
|
40
|
+
case "string":
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
case "boolean":
|
|
43
|
+
return value ? "true" : "false";
|
|
44
|
+
case "number": {
|
|
45
|
+
if (!Number.isFinite(value)) {
|
|
46
|
+
throw new ReservingError("UNSUPPORTED_VALUE", `non-finite number (${String(value)}) at ${path}`);
|
|
47
|
+
}
|
|
48
|
+
return Object.is(value, -0) ? "0" : String(value);
|
|
49
|
+
}
|
|
50
|
+
case "undefined":
|
|
51
|
+
throw new ReservingError("UNSUPPORTED_VALUE", `undefined at ${path}`);
|
|
52
|
+
case "function":
|
|
53
|
+
throw new ReservingError("UNSUPPORTED_VALUE", `function at ${path}`);
|
|
54
|
+
case "bigint":
|
|
55
|
+
case "symbol":
|
|
56
|
+
throw new ReservingError("UNSUPPORTED_VALUE", `${typeof value} at ${path}`);
|
|
57
|
+
case "object":
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const obj = value as object;
|
|
61
|
+
if (seen.has(obj)) {
|
|
62
|
+
throw new ReservingError("UNSUPPORTED_VALUE", `circular reference at ${path}`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(obj);
|
|
65
|
+
let out: string;
|
|
66
|
+
if (Array.isArray(obj)) {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
for (let i = 0; i < obj.length; i++) {
|
|
69
|
+
parts.push(canonicalize(obj[i], `${path}[${i}]`, seen));
|
|
70
|
+
}
|
|
71
|
+
out = `[${parts.join(",")}]`;
|
|
72
|
+
} else if (isPlainObject(obj)) {
|
|
73
|
+
const keys = Object.keys(obj).sort();
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
for (const key of keys) {
|
|
76
|
+
parts.push(`${JSON.stringify(key)}:${canonicalize(obj[key], `${path}.${key}`, seen)}`);
|
|
77
|
+
}
|
|
78
|
+
out = `{${parts.join(",")}}`;
|
|
79
|
+
} else {
|
|
80
|
+
const name = (obj.constructor as { name?: string } | undefined)?.name ?? "unknown";
|
|
81
|
+
throw new ReservingError(
|
|
82
|
+
"UNSUPPORTED_VALUE",
|
|
83
|
+
`non-plain object (${name}) at ${path}; only plain objects, arrays, and JSON primitives are canonicalizable`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
seen.delete(obj);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deterministic JSON serialization: sorted object keys (recursively), arrays
|
|
92
|
+
* in order, no whitespace, -0 normalized to "0". Two structurally equal
|
|
93
|
+
* values always produce the same string regardless of key insertion order.
|
|
94
|
+
* Throws ReservingError("UNSUPPORTED_VALUE") — with the offending path, e.g.
|
|
95
|
+
* "$.rows[2].ultimate" — for any value JSON cannot faithfully represent.
|
|
96
|
+
*/
|
|
97
|
+
export function canonicalJson(value: unknown): string {
|
|
98
|
+
return canonicalize(value, "$", new Set());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
102
|
+
const FNV_PRIME = 0x100000001b3n;
|
|
103
|
+
const MASK_64 = 0xffffffffffffffffn;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* FNV-1a 64-bit hash over the UTF-8 bytes of `text`, returned as a 16-hex-char
|
|
107
|
+
* string.
|
|
108
|
+
*
|
|
109
|
+
* This is an INTEGRITY AID for detecting accidental divergence between a
|
|
110
|
+
* payload and a re-run. It is NOT a security control: FNV-1a is not collision
|
|
111
|
+
* resistant and offers no protection against deliberate tampering. Anyone
|
|
112
|
+
* needing tamper evidence must sign or cryptographically hash the payload.
|
|
113
|
+
*/
|
|
114
|
+
export function fnv1a64(text: string): string {
|
|
115
|
+
const bytes = new TextEncoder().encode(text);
|
|
116
|
+
let hash = FNV_OFFSET_BASIS;
|
|
117
|
+
for (const byte of bytes) {
|
|
118
|
+
hash ^= BigInt(byte);
|
|
119
|
+
hash = (hash * FNV_PRIME) & MASK_64;
|
|
120
|
+
}
|
|
121
|
+
return hash.toString(16).padStart(16, "0");
|
|
122
|
+
}
|