@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
package/src/factors.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { AverageSpec, DevelopmentFactors, Triangle } from "./types.js";
|
|
2
|
+
import { isNum, safeRatio } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Age-to-age (link ratio) development factors and the averages menu.
|
|
6
|
+
*
|
|
7
|
+
* Ground truth:
|
|
8
|
+
* - f[i][j] = C[i][j+1] / C[i][j] on cumulative data.
|
|
9
|
+
* - The volume-weighted average for a column is sum(numerators) / sum(denominators)
|
|
10
|
+
* over exactly the rows where BOTH cells exist -- not the mean of ratios.
|
|
11
|
+
* - Division by a missing, zero, or negative denominator = no factor (null).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_AVERAGES: AverageSpec[] = [
|
|
15
|
+
{ key: "all-wtd", label: "All-year volume-weighted", kind: "weighted" },
|
|
16
|
+
{ key: "all-str", label: "All-year straight", kind: "straight" },
|
|
17
|
+
{ key: "5-wtd", label: "5-year volume-weighted", kind: "weighted", years: 5 },
|
|
18
|
+
{ key: "5-str", label: "5-year straight", kind: "straight", years: 5 },
|
|
19
|
+
{ key: "3-wtd", label: "3-year volume-weighted", kind: "weighted", years: 3 },
|
|
20
|
+
{ key: "3-str", label: "3-year straight", kind: "straight", years: 3 },
|
|
21
|
+
{ key: "med-5x1", label: "Medial 5-year (excl. hi/lo)", kind: "medial", years: 5 },
|
|
22
|
+
{ key: "geo-all", label: "All-year geometric", kind: "geometric" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
interface ColumnPair {
|
|
26
|
+
rowIndex: number;
|
|
27
|
+
numerator: number;
|
|
28
|
+
denominator: number;
|
|
29
|
+
factor: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Valid (both cells present, positive denominator) pairs for column j, in origin order. */
|
|
33
|
+
function columnPairs(tri: Triangle, j: number): ColumnPair[] {
|
|
34
|
+
const pairs: ColumnPair[] = [];
|
|
35
|
+
for (let i = 0; i < tri.origins.length; i++) {
|
|
36
|
+
const den = tri.values[i]![j] ?? null;
|
|
37
|
+
const num = tri.values[i]![j + 1] ?? null;
|
|
38
|
+
const factor = safeRatio(num, den);
|
|
39
|
+
if (factor !== null && isNum(num) && isNum(den)) {
|
|
40
|
+
pairs.push({ rowIndex: i, numerator: num, denominator: den, factor });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return pairs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function averageForColumn(pairs: ColumnPair[], spec: AverageSpec): number | null {
|
|
47
|
+
// An "n-year" average covers the latest n ORIGIN PERIODS that can carry a
|
|
48
|
+
// factor in this column (ending at the last row with a valid factor) -- not
|
|
49
|
+
// the last n valid factors. With an interior missing factor the window must
|
|
50
|
+
// not silently reach further back in time.
|
|
51
|
+
let window = pairs;
|
|
52
|
+
if (spec.years !== undefined && pairs.length > 0) {
|
|
53
|
+
const lastRow = pairs[pairs.length - 1]!.rowIndex;
|
|
54
|
+
window = pairs.filter((p) => p.rowIndex > lastRow - spec.years!);
|
|
55
|
+
}
|
|
56
|
+
if (window.length === 0) return null;
|
|
57
|
+
switch (spec.kind) {
|
|
58
|
+
case "straight": {
|
|
59
|
+
let s = 0;
|
|
60
|
+
for (const p of window) s += p.factor;
|
|
61
|
+
return s / window.length;
|
|
62
|
+
}
|
|
63
|
+
case "weighted": {
|
|
64
|
+
let num = 0;
|
|
65
|
+
let den = 0;
|
|
66
|
+
for (const p of window) {
|
|
67
|
+
num += p.numerator;
|
|
68
|
+
den += p.denominator;
|
|
69
|
+
}
|
|
70
|
+
return den > 0 ? num / den : null;
|
|
71
|
+
}
|
|
72
|
+
case "medial": {
|
|
73
|
+
if (window.length < 3) {
|
|
74
|
+
// Not enough points to exclude hi/lo; fall back to a straight average.
|
|
75
|
+
let s = 0;
|
|
76
|
+
for (const p of window) s += p.factor;
|
|
77
|
+
return s / window.length;
|
|
78
|
+
}
|
|
79
|
+
const sorted = window.map((p) => p.factor).sort((a, b) => a - b);
|
|
80
|
+
const inner = sorted.slice(1, sorted.length - 1);
|
|
81
|
+
let s = 0;
|
|
82
|
+
for (const f of inner) s += f;
|
|
83
|
+
return s / inner.length;
|
|
84
|
+
}
|
|
85
|
+
case "geometric": {
|
|
86
|
+
let logSum = 0;
|
|
87
|
+
for (const p of window) {
|
|
88
|
+
if (p.factor <= 0) return null; // geometric mean undefined
|
|
89
|
+
logSum += Math.log(p.factor);
|
|
90
|
+
}
|
|
91
|
+
return Math.exp(logSum / window.length);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function computeDevelopmentFactors(
|
|
97
|
+
tri: Triangle,
|
|
98
|
+
averageSpecs: AverageSpec[] = DEFAULT_AVERAGES,
|
|
99
|
+
): DevelopmentFactors {
|
|
100
|
+
const nCols = Math.max(0, tri.ages.length - 1);
|
|
101
|
+
const individual: (number | null)[][] = tri.origins.map((_, i) => {
|
|
102
|
+
const row: (number | null)[] = [];
|
|
103
|
+
for (let j = 0; j < nCols; j++) {
|
|
104
|
+
row.push(safeRatio(tri.values[i]![j + 1] ?? null, tri.values[i]![j] ?? null));
|
|
105
|
+
}
|
|
106
|
+
return row;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const pairsByColumn: ColumnPair[][] = [];
|
|
110
|
+
for (let j = 0; j < nCols; j++) pairsByColumn.push(columnPairs(tri, j));
|
|
111
|
+
|
|
112
|
+
const averages = averageSpecs.map((spec) => ({
|
|
113
|
+
spec,
|
|
114
|
+
values: pairsByColumn.map((pairs) => averageForColumn(pairs, spec)),
|
|
115
|
+
}));
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
fromAges: tri.ages.slice(0, nCols),
|
|
119
|
+
toAges: tri.ages.slice(1),
|
|
120
|
+
individual,
|
|
121
|
+
averages,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Per-column coefficient of variation of the individual age-to-age factors:
|
|
127
|
+
* the stability metric the capped-vs-unlimited layer comparison is judged on.
|
|
128
|
+
* null where a column has fewer than two observed factors.
|
|
129
|
+
*/
|
|
130
|
+
export function factorVolatility(dev: DevelopmentFactors): (number | null)[] {
|
|
131
|
+
const nCols = dev.fromAges.length;
|
|
132
|
+
const out: (number | null)[] = new Array(nCols).fill(null);
|
|
133
|
+
for (let j = 0; j < nCols; j++) {
|
|
134
|
+
const values: number[] = [];
|
|
135
|
+
for (const row of dev.individual) {
|
|
136
|
+
const v = row[j];
|
|
137
|
+
if (v !== null && v !== undefined && Number.isFinite(v)) values.push(v);
|
|
138
|
+
}
|
|
139
|
+
if (values.length < 2) continue;
|
|
140
|
+
const mean = values.reduce((a, v) => a + v, 0) / values.length;
|
|
141
|
+
if (mean === 0) continue;
|
|
142
|
+
const variance =
|
|
143
|
+
values.reduce((a, v) => a + (v - mean) ** 2, 0) / (values.length - 1);
|
|
144
|
+
out[j] = Math.sqrt(variance) / Math.abs(mean);
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import type { Triangle } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { cumulativeToIncremental } from "./triangleAlgebra.js";
|
|
4
|
+
import { isNum, lastJointObservedIndex, lastObservedIndex, safeRatio } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fisher-Lange disposal-rate (average-cost-per-claim-settled) method: the
|
|
8
|
+
* reserve is future settlements x trended severity, cell by cell.
|
|
9
|
+
*
|
|
10
|
+
* Ground truth:
|
|
11
|
+
* - Disposal rates d[i][j] = incremental CLOSED counts / SUPPLIED ultimate
|
|
12
|
+
* counts (the ultimates are the caller's independent estimate - e.g. a
|
|
13
|
+
* count chain ladder - never derived here). The rate used per age is the
|
|
14
|
+
* caller's `disposalSelections[j]` or, by default, the latest observed
|
|
15
|
+
* diagonal rate for that age.
|
|
16
|
+
* - Future closed counts for origin i at a future age j = ultimateCounts[i]
|
|
17
|
+
* x selected d[j].
|
|
18
|
+
* - Severities by settlement age: incremental paid / incremental closed per
|
|
19
|
+
* cell. The selected severity for age j is the closed-count-weighted
|
|
20
|
+
* average of the observed cell severities, each trended FROM its cell's
|
|
21
|
+
* calendar period TO the latest observed diagonal (the internal
|
|
22
|
+
* reference).
|
|
23
|
+
* - Reserve = sum over future cells of counts x severity trended to that
|
|
24
|
+
* cell's calendar period.
|
|
25
|
+
*
|
|
26
|
+
* Severity trend convention (compounds by CALENDAR distance): a cell's
|
|
27
|
+
* calendar position is originIndex x ageStep + age (months), where the
|
|
28
|
+
* origin cadence is inferred from the (required equal) development-age
|
|
29
|
+
* spacing. A severity moved y calendar years is multiplied by
|
|
30
|
+
* (1 + severityTrend)^y, fractional years allowed (quarterly triangles
|
|
31
|
+
* compound by quarter-year fractions). Because every observed severity is
|
|
32
|
+
* trended to one reference and then forward to each future cell, the
|
|
33
|
+
* projection is invariant to the reference choice; `targetYear` only shifts
|
|
34
|
+
* the calendar year the DISPLAYED selectedSeverities are stated at.
|
|
35
|
+
*
|
|
36
|
+
* Projection zeros are loud, never silent: an age with no selectable
|
|
37
|
+
* disposal rate projects zero closures with a warning, and a needed age
|
|
38
|
+
* with no observable severity projects zero payment with a warning (the
|
|
39
|
+
* "sparse cell" warnings).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface FisherLangeOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Selected disposal rate per development age (length = ages.length);
|
|
45
|
+
* null falls back to the latest observed diagonal rate for that age.
|
|
46
|
+
*/
|
|
47
|
+
disposalSelections?: (number | null)[];
|
|
48
|
+
/** Annual severity trend (e.g. 0.05 = +5%/calendar year). */
|
|
49
|
+
severityTrend: number;
|
|
50
|
+
/**
|
|
51
|
+
* Calendar year the displayed selectedSeverities are stated at (default:
|
|
52
|
+
* the latest diagonal's year). Display only - the reserve is invariant.
|
|
53
|
+
* Requires an annual triangle (12-month spacing, first age 12) with
|
|
54
|
+
* numeric origin labels; otherwise ignored with a warning.
|
|
55
|
+
*/
|
|
56
|
+
targetYear?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface FisherLangeRow {
|
|
60
|
+
origin: string;
|
|
61
|
+
/** Age (months) of the latest cell observed in BOTH triangles. */
|
|
62
|
+
latestAge: number;
|
|
63
|
+
paidToDate: number;
|
|
64
|
+
closedToDate: number;
|
|
65
|
+
ultimateCounts: number;
|
|
66
|
+
/** Future closed counts per remaining age column (after latestAge). */
|
|
67
|
+
futureClosedCounts: number[];
|
|
68
|
+
/**
|
|
69
|
+
* Trended severity applied per remaining age column; null where no
|
|
70
|
+
* severity was observable (that cell projected zero payment, warned).
|
|
71
|
+
*/
|
|
72
|
+
futureSeverities: (number | null)[];
|
|
73
|
+
unpaid: number;
|
|
74
|
+
ultimate: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FisherLangeResult {
|
|
78
|
+
method: "fisherLange";
|
|
79
|
+
/** Historical incremental severities (paid / closed) per cell. */
|
|
80
|
+
severities: (number | null)[][];
|
|
81
|
+
/** Historical disposal rates (incremental closed / ultimate counts). */
|
|
82
|
+
disposalRates: (number | null)[][];
|
|
83
|
+
/** Disposal rate used per age (caller's selection or diagonal default). */
|
|
84
|
+
selectedDisposalRates: (number | null)[];
|
|
85
|
+
/** Selected severity per age, stated at referenceYear (display only). */
|
|
86
|
+
selectedSeverities: (number | null)[];
|
|
87
|
+
/** Calendar year selectedSeverities are stated at; null when origin labels don't parse. */
|
|
88
|
+
referenceYear: number | null;
|
|
89
|
+
ultimateCounts: number[];
|
|
90
|
+
rows: FisherLangeRow[];
|
|
91
|
+
totals: { paidToDate: number; unpaid: number; ultimate: number };
|
|
92
|
+
warnings: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertSameShape(a: Triangle, b: Triangle): void {
|
|
96
|
+
const sameOrigins =
|
|
97
|
+
a.origins.length === b.origins.length && a.origins.every((o, i) => o === b.origins[i]);
|
|
98
|
+
const sameAges = a.ages.length === b.ages.length && a.ages.every((v, j) => v === b.ages[j]);
|
|
99
|
+
if (!sameOrigins || !sameAges) {
|
|
100
|
+
throw new ReservingError(
|
|
101
|
+
"SHAPE",
|
|
102
|
+
"The paid and closed-count triangles must share identical origins and development ages",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function runFisherLange(
|
|
108
|
+
paid: Triangle,
|
|
109
|
+
closedCounts: Triangle,
|
|
110
|
+
ultimateCounts: number[],
|
|
111
|
+
options: FisherLangeOptions,
|
|
112
|
+
): FisherLangeResult {
|
|
113
|
+
assertSameShape(paid, closedCounts);
|
|
114
|
+
const nOrigins = paid.origins.length;
|
|
115
|
+
const nAges = paid.ages.length;
|
|
116
|
+
if (nAges < 2) {
|
|
117
|
+
throw new ReservingError("TOO_SMALL", "Fisher-Lange requires at least two development ages");
|
|
118
|
+
}
|
|
119
|
+
const step = paid.ages[1]! - paid.ages[0]!;
|
|
120
|
+
for (let j = 1; j < nAges; j++) {
|
|
121
|
+
if (paid.ages[j]! - paid.ages[j - 1]! !== step) {
|
|
122
|
+
throw new ReservingError(
|
|
123
|
+
"SHAPE",
|
|
124
|
+
"Fisher-Lange infers calendar distance from the age step, which requires equally spaced development ages",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (ultimateCounts.length !== nOrigins) {
|
|
129
|
+
throw new ReservingError(
|
|
130
|
+
"SHAPE",
|
|
131
|
+
`Expected ${nOrigins} ultimate claim counts (one per origin), got ${ultimateCounts.length}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
ultimateCounts.forEach((u, i) => {
|
|
135
|
+
if (!isNum(u) || u < 0) {
|
|
136
|
+
throw new ReservingError(
|
|
137
|
+
"BAD_COUNTS",
|
|
138
|
+
`Ultimate counts must be finite and non-negative; origin ${paid.origins[i]} has ${u}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
if (!isNum(options.severityTrend) || options.severityTrend <= -1) {
|
|
143
|
+
throw new ReservingError("BAD_TREND", "Severity trend must be greater than -100%");
|
|
144
|
+
}
|
|
145
|
+
if (options.disposalSelections !== undefined && options.disposalSelections.length !== nAges) {
|
|
146
|
+
throw new ReservingError(
|
|
147
|
+
"SELECTION_SHAPE",
|
|
148
|
+
`Expected ${nAges} disposal-rate selections (one per age), got ${options.disposalSelections.length}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (options.targetYear !== undefined && !Number.isInteger(options.targetYear)) {
|
|
152
|
+
throw new ReservingError(
|
|
153
|
+
"BAD_DATE",
|
|
154
|
+
`targetYear must be an integer calendar year, got ${options.targetYear}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const warnings: string[] = [];
|
|
159
|
+
const growth = 1 + options.severityTrend;
|
|
160
|
+
const incPaid = cumulativeToIncremental(paid).values;
|
|
161
|
+
const incClosed = cumulativeToIncremental(closedCounts).values;
|
|
162
|
+
const calMonths = (i: number, j: number): number => i * step + paid.ages[j]!;
|
|
163
|
+
|
|
164
|
+
// Calendar guard: calMonths places origins by array index, which assumes
|
|
165
|
+
// consecutive origin periods. For annual triangles with numeric year
|
|
166
|
+
// labels the assumption is checkable for free - a gap (e.g. 2020, 2023,
|
|
167
|
+
// 2024) silently compresses severity-trend distances, so warn loudly.
|
|
168
|
+
// Quarterly/non-numeric labels stay unchecked (integer label spacing can
|
|
169
|
+
// never equal a quarterly step; the cadence inference is documented above).
|
|
170
|
+
if (step === 12 && paid.origins.every((o) => /^\d+$/.test(o))) {
|
|
171
|
+
for (let i = 1; i < nOrigins; i++) {
|
|
172
|
+
const gap = Number(paid.origins[i]) - Number(paid.origins[i - 1]);
|
|
173
|
+
if (gap !== 1) {
|
|
174
|
+
warnings.push(
|
|
175
|
+
`Origins ${paid.origins[i - 1]} and ${paid.origins[i]} are ${gap} years apart but are trended as consecutive; with a nonzero severity trend the reserve is misstated - insert explicit null rows for the missing years`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Historical severity and disposal-rate triangles (null-safe by safeRatio).
|
|
182
|
+
const severities: (number | null)[][] = [];
|
|
183
|
+
const disposalRates: (number | null)[][] = [];
|
|
184
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
185
|
+
const ult = ultimateCounts[i]!;
|
|
186
|
+
severities.push(
|
|
187
|
+
paid.ages.map((_, j) => safeRatio(incPaid[i]![j] ?? null, incClosed[i]![j] ?? null)),
|
|
188
|
+
);
|
|
189
|
+
disposalRates.push(
|
|
190
|
+
paid.ages.map((_, j) => safeRatio(incClosed[i]![j] ?? null, ult > 0 ? ult : null)),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Reference: the latest observed calendar position across both incremental
|
|
195
|
+
// triangles. All severity statements below are internally at REF.
|
|
196
|
+
let refMonths = -1;
|
|
197
|
+
let refI = -1;
|
|
198
|
+
let refJ = -1;
|
|
199
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
200
|
+
for (let j = 0; j < nAges; j++) {
|
|
201
|
+
if (!isNum(incPaid[i]![j] ?? null) && !isNum(incClosed[i]![j] ?? null)) continue;
|
|
202
|
+
const cal = calMonths(i, j);
|
|
203
|
+
if (cal > refMonths) {
|
|
204
|
+
refMonths = cal;
|
|
205
|
+
refI = i;
|
|
206
|
+
refJ = j;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (refMonths < 0) {
|
|
211
|
+
throw new ReservingError("NO_DATA", "No observed cells in the paid or closed-count triangles");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Selected disposal rate per age: caller's, else latest diagonal.
|
|
215
|
+
const diagonalDisposal = (j: number): number | null => {
|
|
216
|
+
for (let i = nOrigins - 1; i >= 0; i--) {
|
|
217
|
+
const d = disposalRates[i]![j] ?? null;
|
|
218
|
+
if (isNum(d)) return d;
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
};
|
|
222
|
+
const selectedDisposalRates: (number | null)[] = paid.ages.map((age, j) => {
|
|
223
|
+
const caller = options.disposalSelections?.[j] ?? null;
|
|
224
|
+
if (isNum(caller)) {
|
|
225
|
+
if (caller >= 0) return caller;
|
|
226
|
+
warnings.push(
|
|
227
|
+
`Negative disposal-rate selection for age ${age} months; ignored in favor of the latest diagonal rate`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return diagonalDisposal(j);
|
|
231
|
+
});
|
|
232
|
+
const disposalSum = selectedDisposalRates.reduce<number>((a, d) => a + (isNum(d) ? d : 0), 0);
|
|
233
|
+
if (Math.abs(disposalSum - 1) > 0.01) {
|
|
234
|
+
warnings.push(
|
|
235
|
+
`Selected disposal rates sum to ${disposalSum.toFixed(4)} across all ages; ` +
|
|
236
|
+
(disposalSum < 1
|
|
237
|
+
? "claims closing beyond the last development age are NOT projected"
|
|
238
|
+
: "more closures than ultimate counts are projected - review the pattern"),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Selected severity per age at REF: closed-count-weighted average of the
|
|
243
|
+
// observed cell severities, each trended to REF.
|
|
244
|
+
const selectedSevAtRef: (number | null)[] = paid.ages.map((_, j) => {
|
|
245
|
+
let trendedPaid = 0;
|
|
246
|
+
let closed = 0;
|
|
247
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
248
|
+
if (!isNum(severities[i]![j] ?? null)) continue;
|
|
249
|
+
trendedPaid += incPaid[i]![j]! * growth ** ((refMonths - calMonths(i, j)) / 12);
|
|
250
|
+
closed += incClosed[i]![j]!;
|
|
251
|
+
}
|
|
252
|
+
return safeRatio(trendedPaid, closed);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Projection.
|
|
256
|
+
const warnedNoDisposal = new Set<number>();
|
|
257
|
+
const warnedNoSeverity = new Set<number>();
|
|
258
|
+
const rows: FisherLangeRow[] = [];
|
|
259
|
+
for (let i = 0; i < nOrigins; i++) {
|
|
260
|
+
const paidRow = paid.values[i]!;
|
|
261
|
+
const closedRow = closedCounts.values[i]!;
|
|
262
|
+
const lastPaid = lastObservedIndex(paidRow);
|
|
263
|
+
const lastClosed = lastObservedIndex(closedRow);
|
|
264
|
+
const k = lastJointObservedIndex(paidRow, closedRow);
|
|
265
|
+
if (k < 0) {
|
|
266
|
+
warnings.push(
|
|
267
|
+
`Origin ${paid.origins[i]} has no age with both paid and closed counts observed; excluded from results`,
|
|
268
|
+
);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (lastPaid !== lastClosed) {
|
|
272
|
+
warnings.push(
|
|
273
|
+
`Origin ${paid.origins[i]}: paid and closed-count diagonals end at different ages (${paid.ages[lastPaid]} vs ${paid.ages[lastClosed]}); projected from the latest jointly observed age ${paid.ages[k]}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const futureClosedCounts: number[] = [];
|
|
278
|
+
const futureSeverities: (number | null)[] = [];
|
|
279
|
+
let unpaid = 0;
|
|
280
|
+
for (let j = k + 1; j < nAges; j++) {
|
|
281
|
+
const d = selectedDisposalRates[j];
|
|
282
|
+
if (!isNum(d)) {
|
|
283
|
+
if (!warnedNoDisposal.has(j)) {
|
|
284
|
+
warnedNoDisposal.add(j);
|
|
285
|
+
warnings.push(
|
|
286
|
+
`No disposal rate is selectable for age ${paid.ages[j]} months (sparse column); future closures there are projected as zero`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
futureClosedCounts.push(0);
|
|
290
|
+
futureSeverities.push(null);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const counts = ultimateCounts[i]! * d;
|
|
294
|
+
futureClosedCounts.push(counts);
|
|
295
|
+
const sevRef = selectedSevAtRef[j];
|
|
296
|
+
if (!isNum(sevRef)) {
|
|
297
|
+
if (counts > 0 && !warnedNoSeverity.has(j)) {
|
|
298
|
+
warnedNoSeverity.add(j);
|
|
299
|
+
warnings.push(
|
|
300
|
+
`No severity is observable for age ${paid.ages[j]} months (sparse column); payments there are projected as zero`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
futureSeverities.push(null);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
const sev = sevRef * growth ** ((calMonths(i, j) - refMonths) / 12);
|
|
307
|
+
futureSeverities.push(sev);
|
|
308
|
+
unpaid += counts * sev;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const paidToDate = paidRow[k]!;
|
|
312
|
+
rows.push({
|
|
313
|
+
origin: paid.origins[i]!,
|
|
314
|
+
latestAge: paid.ages[k]!,
|
|
315
|
+
paidToDate,
|
|
316
|
+
closedToDate: closedRow[k]!,
|
|
317
|
+
ultimateCounts: ultimateCounts[i]!,
|
|
318
|
+
futureClosedCounts,
|
|
319
|
+
futureSeverities,
|
|
320
|
+
unpaid,
|
|
321
|
+
ultimate: paidToDate + unpaid,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (rows.length === 0) {
|
|
325
|
+
throw new ReservingError(
|
|
326
|
+
"NO_DATA",
|
|
327
|
+
"No origin period has both paid and closed counts observed at any age",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Display statement year for the selected severities. Derivable only for
|
|
332
|
+
// annual triangles whose first age is 12 months and whose origin labels
|
|
333
|
+
// are numeric years; the projection above never depends on it.
|
|
334
|
+
let referenceYear: number | null = null;
|
|
335
|
+
if (step === 12 && paid.ages[0] === 12 && /^\d+$/.test(paid.origins[refI] ?? "")) {
|
|
336
|
+
referenceYear = Number(paid.origins[refI]) + refJ;
|
|
337
|
+
}
|
|
338
|
+
let displayShiftYears = 0;
|
|
339
|
+
if (options.targetYear !== undefined) {
|
|
340
|
+
if (referenceYear === null) {
|
|
341
|
+
warnings.push(
|
|
342
|
+
"targetYear ignored: stating severities at a calendar year needs an annual triangle (12-month spacing, first age 12) with numeric origin labels",
|
|
343
|
+
);
|
|
344
|
+
} else {
|
|
345
|
+
displayShiftYears = options.targetYear - referenceYear;
|
|
346
|
+
referenceYear = options.targetYear;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const selectedSeverities = selectedSevAtRef.map((s) =>
|
|
350
|
+
isNum(s) ? s * growth ** displayShiftYears : null,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const totals = rows.reduce(
|
|
354
|
+
(acc, r) => ({
|
|
355
|
+
paidToDate: acc.paidToDate + r.paidToDate,
|
|
356
|
+
unpaid: acc.unpaid + r.unpaid,
|
|
357
|
+
ultimate: acc.ultimate + r.ultimate,
|
|
358
|
+
}),
|
|
359
|
+
{ paidToDate: 0, unpaid: 0, ultimate: 0 },
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
method: "fisherLange",
|
|
364
|
+
severities,
|
|
365
|
+
disposalRates,
|
|
366
|
+
selectedDisposalRates,
|
|
367
|
+
selectedSeverities,
|
|
368
|
+
referenceYear,
|
|
369
|
+
ultimateCounts: [...ultimateCounts],
|
|
370
|
+
rows,
|
|
371
|
+
totals,
|
|
372
|
+
warnings,
|
|
373
|
+
};
|
|
374
|
+
}
|
package/src/freqSev.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { TriangleKind, LdfSelections, Triangle } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { isNum, lastObservedIndex, safeRatio } from "./util.js";
|
|
4
|
+
import { runChainLadder } from "./chainladder.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Frequency-severity ultimate method (Friedland, "Estimating Unpaid Claims
|
|
8
|
+
* Using Basic Techniques", ch. 11, the development technique applied to
|
|
9
|
+
* claim counts and average values).
|
|
10
|
+
*
|
|
11
|
+
* Ground truth:
|
|
12
|
+
* - Ultimate claims_i = ultimate counts_i x ultimate severity_i.
|
|
13
|
+
* - Ultimate counts: chain ladder on the (reported) count triangle with the
|
|
14
|
+
* caller's selected count LDFs and tail.
|
|
15
|
+
* - Ultimate severity: chain ladder on the average-severity triangle
|
|
16
|
+
* (losses / counts, cell-wise and null-safe) with the caller's selected
|
|
17
|
+
* severity LDFs and tail — developing average values is the standard
|
|
18
|
+
* Friedland treatment.
|
|
19
|
+
* - Selections are the CALLER's judgment on both bases, per the engine-wide
|
|
20
|
+
* rule; this module never picks factors.
|
|
21
|
+
*
|
|
22
|
+
* Caveat the caller must own (doc, not warning): severity here is per
|
|
23
|
+
* REPORTED claim including closed-without-payment; a drifting CWP share
|
|
24
|
+
* masquerades as severity development. Check closure diagnostics before
|
|
25
|
+
* leaning on this method.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface FrequencySeverityOptions {
|
|
29
|
+
/** Selected count LDFs, one per development interval (null = unselected -> 1.000 with a warning). */
|
|
30
|
+
countSelected: LdfSelections["selected"];
|
|
31
|
+
countTailFactor?: number;
|
|
32
|
+
/** Selected severity LDFs on the average-severity triangle. */
|
|
33
|
+
severitySelected: LdfSelections["selected"];
|
|
34
|
+
severityTailFactor?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FrequencySeverityRow {
|
|
38
|
+
origin: string;
|
|
39
|
+
/** Latest observed value on the LOSS triangle (the dollars basis). */
|
|
40
|
+
latestValue: number;
|
|
41
|
+
ultimateCounts: number;
|
|
42
|
+
ultimateSeverity: number;
|
|
43
|
+
ultimate: number;
|
|
44
|
+
unpaid: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FrequencySeverityResult {
|
|
48
|
+
method: "frequencySeverity";
|
|
49
|
+
basis: TriangleKind;
|
|
50
|
+
rows: FrequencySeverityRow[];
|
|
51
|
+
totals: { latest: number; ultimate: number; unpaid: number };
|
|
52
|
+
warnings: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Average-severity triangle: cell-wise losses / counts. Null-safe: a
|
|
57
|
+
* missing, zero, or negative count yields a null severity cell.
|
|
58
|
+
*/
|
|
59
|
+
export function severityTriangle(lossTri: Triangle, countTri: Triangle): Triangle {
|
|
60
|
+
assertSameShape(lossTri, countTri);
|
|
61
|
+
return {
|
|
62
|
+
kind: lossTri.kind,
|
|
63
|
+
origins: [...lossTri.origins],
|
|
64
|
+
ages: [...lossTri.ages],
|
|
65
|
+
values: lossTri.values.map((row, i) =>
|
|
66
|
+
row.map((loss, j) => safeRatio(loss ?? null, countTri.values[i]![j] ?? null)),
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertSameShape(a: Triangle, b: Triangle): void {
|
|
72
|
+
const sameOrigins =
|
|
73
|
+
a.origins.length === b.origins.length && a.origins.every((o, i) => o === b.origins[i]);
|
|
74
|
+
const sameAges = a.ages.length === b.ages.length && a.ages.every((v, i) => v === b.ages[i]);
|
|
75
|
+
if (!sameOrigins || !sameAges) {
|
|
76
|
+
throw new ReservingError(
|
|
77
|
+
"SHAPE",
|
|
78
|
+
"The loss and count triangles must share identical origins and development ages",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function runFrequencySeverity(
|
|
84
|
+
lossTri: Triangle,
|
|
85
|
+
countTri: Triangle,
|
|
86
|
+
options: FrequencySeverityOptions,
|
|
87
|
+
): FrequencySeverityResult {
|
|
88
|
+
assertSameShape(lossTri, countTri);
|
|
89
|
+
const warnings: string[] = [];
|
|
90
|
+
|
|
91
|
+
const countCl = runChainLadder(countTri, {
|
|
92
|
+
selected: options.countSelected,
|
|
93
|
+
tailFactor: options.countTailFactor ?? 1,
|
|
94
|
+
});
|
|
95
|
+
for (const w of countCl.warnings) warnings.push(`counts: ${w}`);
|
|
96
|
+
|
|
97
|
+
const sevTri = severityTriangle(lossTri, countTri);
|
|
98
|
+
const sevCl = runChainLadder(sevTri, {
|
|
99
|
+
selected: options.severitySelected,
|
|
100
|
+
tailFactor: options.severityTailFactor ?? 1,
|
|
101
|
+
});
|
|
102
|
+
for (const w of sevCl.warnings) warnings.push(`severity: ${w}`);
|
|
103
|
+
|
|
104
|
+
const countByOrigin = new Map(countCl.rows.map((r) => [r.origin, r]));
|
|
105
|
+
const sevByOrigin = new Map(sevCl.rows.map((r) => [r.origin, r]));
|
|
106
|
+
|
|
107
|
+
const rows: FrequencySeverityRow[] = [];
|
|
108
|
+
for (let i = 0; i < lossTri.origins.length; i++) {
|
|
109
|
+
const origin = lossTri.origins[i]!;
|
|
110
|
+
const counts = countByOrigin.get(origin);
|
|
111
|
+
const sev = sevByOrigin.get(origin);
|
|
112
|
+
if (!counts || !sev) {
|
|
113
|
+
warnings.push(
|
|
114
|
+
`Origin ${origin} has no observed counts or severities; excluded from frequency-severity`,
|
|
115
|
+
);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const latestIdx = lastObservedIndex(lossTri.values[i]!);
|
|
119
|
+
const latestValue = latestIdx >= 0 ? lossTri.values[i]![latestIdx]! : 0;
|
|
120
|
+
const ultimate = counts.ultimate * sev.ultimate;
|
|
121
|
+
if (!isNum(ultimate)) {
|
|
122
|
+
warnings.push(`Origin ${origin}: the frequency-severity ultimate is not computable`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
rows.push({
|
|
126
|
+
origin,
|
|
127
|
+
latestValue,
|
|
128
|
+
ultimateCounts: counts.ultimate,
|
|
129
|
+
ultimateSeverity: sev.ultimate,
|
|
130
|
+
ultimate,
|
|
131
|
+
unpaid: ultimate - latestValue,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (rows.length === 0) {
|
|
135
|
+
throw new ReservingError(
|
|
136
|
+
"NO_DATA",
|
|
137
|
+
"No origin period has both observed counts and observed severities",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
method: "frequencySeverity",
|
|
143
|
+
basis: lossTri.kind,
|
|
144
|
+
rows,
|
|
145
|
+
totals: {
|
|
146
|
+
latest: rows.reduce((a, r) => a + r.latestValue, 0),
|
|
147
|
+
ultimate: rows.reduce((a, r) => a + r.ultimate, 0),
|
|
148
|
+
unpaid: rows.reduce((a, r) => a + r.unpaid, 0),
|
|
149
|
+
},
|
|
150
|
+
warnings,
|
|
151
|
+
};
|
|
152
|
+
}
|