@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
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { runChainLadder } from "./chainladder.js";
|
|
2
|
+
import type { ChainLadderResult, LdfSelections, Triangle, TriangleKind } from "./types.js";
|
|
3
|
+
import { ReservingError } from "./types.js";
|
|
4
|
+
import { isNum } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Salvage & subrogation: recovery triangles develop like loss triangles, so
|
|
8
|
+
* ultimate recoveries come from a chain ladder on cumulative RECEIVED
|
|
9
|
+
* recoveries with the caller's selected factors, and net results come from
|
|
10
|
+
* subtracting the recovery projection from a gross one, aligned by origin.
|
|
11
|
+
*
|
|
12
|
+
* Ground truth:
|
|
13
|
+
* - Recovery development is commonly SLOWER and LUMPIER than the loss
|
|
14
|
+
* development it offsets (recoveries lag settlement); every run says so in
|
|
15
|
+
* warnings, because borrowing loss LDFs for recoveries is the classic
|
|
16
|
+
* mistake this module exists to prevent.
|
|
17
|
+
* - `netOfRecoveries` never fabricates: an origin present on only one side
|
|
18
|
+
* is EXCLUDED with a warning, and both sides' totals cover only the
|
|
19
|
+
* aligned origins so net = gross - recoveries holds exactly within the
|
|
20
|
+
* result.
|
|
21
|
+
* - Algebra identity (tested): with the same selections on both triangles,
|
|
22
|
+
* netting the two projections equals a chain ladder on the cell-wise
|
|
23
|
+
* difference triangle (subtractTriangles), because CL is linear in the
|
|
24
|
+
* latest diagonal.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface SalvageSubroOptions {
|
|
28
|
+
/** Selected recovery LDFs, one per development interval (null -> 1.000 with a warning). */
|
|
29
|
+
selected: LdfSelections["selected"];
|
|
30
|
+
tailFactor?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SalvageSubroRow {
|
|
34
|
+
origin: string;
|
|
35
|
+
latestAge: number;
|
|
36
|
+
/** Cumulative recoveries received as of the latest diagonal. */
|
|
37
|
+
receivedToDate: number;
|
|
38
|
+
/** Cumulative development factor from latestAge to ultimate. */
|
|
39
|
+
cdf: number;
|
|
40
|
+
ultimateRecoveries: number;
|
|
41
|
+
/** ultimate - received: recoveries still expected. */
|
|
42
|
+
futureRecoveries: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SalvageSubroResult {
|
|
46
|
+
method: "salvageSubro";
|
|
47
|
+
basis: TriangleKind;
|
|
48
|
+
cdfs: number[];
|
|
49
|
+
percentDeveloped: number[];
|
|
50
|
+
rows: SalvageSubroRow[];
|
|
51
|
+
totals: { receivedToDate: number; ultimateRecoveries: number; futureRecoveries: number };
|
|
52
|
+
warnings: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Chain ladder on a cumulative received-recoveries triangle. */
|
|
56
|
+
export function runSalvageSubro(
|
|
57
|
+
recoveries: Triangle,
|
|
58
|
+
options: SalvageSubroOptions,
|
|
59
|
+
): SalvageSubroResult {
|
|
60
|
+
const warnings: string[] = [
|
|
61
|
+
"Salvage and subrogation typically develop more slowly and less smoothly than the losses they offset; select recovery factors from recovery experience rather than borrowing loss LDFs",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const negativeOrigins = recoveries.origins.filter((_, i) =>
|
|
65
|
+
recoveries.values[i]!.some((v) => isNum(v ?? null) && v! < 0),
|
|
66
|
+
);
|
|
67
|
+
if (negativeOrigins.length > 0) {
|
|
68
|
+
warnings.push(
|
|
69
|
+
`Negative cumulative recoveries for origin(s) ${negativeOrigins.join(", ")}; received salvage/subrogation should be non-negative - verify the data`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cl = runChainLadder(recoveries, {
|
|
74
|
+
selected: options.selected,
|
|
75
|
+
tailFactor: options.tailFactor ?? 1,
|
|
76
|
+
});
|
|
77
|
+
warnings.push(...cl.warnings);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
method: "salvageSubro",
|
|
81
|
+
basis: cl.basis,
|
|
82
|
+
cdfs: cl.cdfs,
|
|
83
|
+
percentDeveloped: cl.percentDeveloped,
|
|
84
|
+
rows: cl.rows.map((r) => ({
|
|
85
|
+
origin: r.origin,
|
|
86
|
+
latestAge: r.latestAge,
|
|
87
|
+
receivedToDate: r.latestValue,
|
|
88
|
+
cdf: r.cdf,
|
|
89
|
+
ultimateRecoveries: r.ultimate,
|
|
90
|
+
futureRecoveries: r.unpaid,
|
|
91
|
+
})),
|
|
92
|
+
totals: {
|
|
93
|
+
receivedToDate: cl.totals.latest,
|
|
94
|
+
ultimateRecoveries: cl.totals.ultimate,
|
|
95
|
+
futureRecoveries: cl.totals.unpaid,
|
|
96
|
+
},
|
|
97
|
+
warnings,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface NetOfRecoveriesRow {
|
|
102
|
+
origin: string;
|
|
103
|
+
grossUltimate: number;
|
|
104
|
+
ultimateRecoveries: number;
|
|
105
|
+
netUltimate: number;
|
|
106
|
+
grossUnpaid: number;
|
|
107
|
+
futureRecoveries: number;
|
|
108
|
+
netUnpaid: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface NetOfRecoveriesResult {
|
|
112
|
+
method: "netOfRecoveries";
|
|
113
|
+
basis: TriangleKind;
|
|
114
|
+
rows: NetOfRecoveriesRow[];
|
|
115
|
+
totals: {
|
|
116
|
+
grossUltimate: number;
|
|
117
|
+
ultimateRecoveries: number;
|
|
118
|
+
netUltimate: number;
|
|
119
|
+
grossUnpaid: number;
|
|
120
|
+
futureRecoveries: number;
|
|
121
|
+
netUnpaid: number;
|
|
122
|
+
};
|
|
123
|
+
warnings: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Nets a gross chain ladder against a recovery projection, aligned by origin
|
|
128
|
+
* label. Origins missing on either side are warned and EXCLUDED - never
|
|
129
|
+
* zero-filled - so the totals net exactly over the aligned origins.
|
|
130
|
+
*/
|
|
131
|
+
export function netOfRecoveries(
|
|
132
|
+
gross: ChainLadderResult,
|
|
133
|
+
recoveries: SalvageSubroResult,
|
|
134
|
+
): NetOfRecoveriesResult {
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
const recoveryByOrigin = new Map(recoveries.rows.map((r) => [r.origin, r]));
|
|
137
|
+
const grossOrigins = new Set(gross.rows.map((r) => r.origin));
|
|
138
|
+
|
|
139
|
+
const rows: NetOfRecoveriesRow[] = [];
|
|
140
|
+
const negativeNetOrigins: string[] = [];
|
|
141
|
+
for (const g of gross.rows) {
|
|
142
|
+
const rec = recoveryByOrigin.get(g.origin);
|
|
143
|
+
if (!rec) {
|
|
144
|
+
warnings.push(
|
|
145
|
+
`Origin ${g.origin} has no recovery projection; excluded from the net results (never zero-filled)`,
|
|
146
|
+
);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (g.latestAge !== rec.latestAge) {
|
|
150
|
+
warnings.push(
|
|
151
|
+
`Origin ${g.origin}: gross projected from age ${g.latestAge} months but recoveries from age ${rec.latestAge}; the net mixes valuation dates`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const row: NetOfRecoveriesRow = {
|
|
155
|
+
origin: g.origin,
|
|
156
|
+
grossUltimate: g.ultimate,
|
|
157
|
+
ultimateRecoveries: rec.ultimateRecoveries,
|
|
158
|
+
netUltimate: g.ultimate - rec.ultimateRecoveries,
|
|
159
|
+
grossUnpaid: g.unpaid,
|
|
160
|
+
futureRecoveries: rec.futureRecoveries,
|
|
161
|
+
netUnpaid: g.unpaid - rec.futureRecoveries,
|
|
162
|
+
};
|
|
163
|
+
if (row.netUltimate < 0 || row.netUnpaid < 0) negativeNetOrigins.push(g.origin);
|
|
164
|
+
rows.push(row);
|
|
165
|
+
}
|
|
166
|
+
for (const rec of recoveries.rows) {
|
|
167
|
+
if (!grossOrigins.has(rec.origin)) {
|
|
168
|
+
warnings.push(
|
|
169
|
+
`Origin ${rec.origin} has a recovery projection but no gross result; excluded from the net results (never zero-filled)`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (rows.length === 0) {
|
|
174
|
+
throw new ReservingError(
|
|
175
|
+
"NO_DATA",
|
|
176
|
+
"No origin appears in both the gross and recovery projections",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (negativeNetOrigins.length > 0) {
|
|
180
|
+
warnings.push(
|
|
181
|
+
`Projected recoveries exceed gross losses for origin(s) ${negativeNetOrigins.join(", ")}; the net result is negative - verify the recovery development`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const totals = rows.reduce(
|
|
186
|
+
(acc, r) => ({
|
|
187
|
+
grossUltimate: acc.grossUltimate + r.grossUltimate,
|
|
188
|
+
ultimateRecoveries: acc.ultimateRecoveries + r.ultimateRecoveries,
|
|
189
|
+
netUltimate: acc.netUltimate + r.netUltimate,
|
|
190
|
+
grossUnpaid: acc.grossUnpaid + r.grossUnpaid,
|
|
191
|
+
futureRecoveries: acc.futureRecoveries + r.futureRecoveries,
|
|
192
|
+
netUnpaid: acc.netUnpaid + r.netUnpaid,
|
|
193
|
+
}),
|
|
194
|
+
{
|
|
195
|
+
grossUltimate: 0,
|
|
196
|
+
ultimateRecoveries: 0,
|
|
197
|
+
netUltimate: 0,
|
|
198
|
+
grossUnpaid: 0,
|
|
199
|
+
futureRecoveries: 0,
|
|
200
|
+
netUnpaid: 0,
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return { method: "netOfRecoveries", basis: gross.basis, rows, totals, warnings };
|
|
205
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
import { isNum } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stochastic infrastructure: a seeded, reproducible RNG and the shared
|
|
6
|
+
* result shape every simulation-based method returns.
|
|
7
|
+
*
|
|
8
|
+
* Ground truth:
|
|
9
|
+
* - NO ambient randomness anywhere in the engine: every stochastic method
|
|
10
|
+
* takes an explicit integer seed, and the same seed + same inputs must
|
|
11
|
+
* reproduce the same output bit for bit (the reproducibility-bundle
|
|
12
|
+
* contract depends on it).
|
|
13
|
+
* - The generator is mulberry32: tiny, fast, well-distributed for
|
|
14
|
+
* simulation purposes. It is NOT cryptographic and does not need to be.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface Rng {
|
|
18
|
+
/** Uniform on [0, 1). */
|
|
19
|
+
next(): number;
|
|
20
|
+
/** Standard normal (Box-Muller with cached spare). */
|
|
21
|
+
normal(): number;
|
|
22
|
+
/**
|
|
23
|
+
* Gamma(shape, scale = 1) via Marsaglia-Tsang squeeze (shape >= 1) with
|
|
24
|
+
* the Ahrens-Dieter boost for shape < 1.
|
|
25
|
+
*/
|
|
26
|
+
gamma(shape: number): number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic seeded RNG. Same seed = same stream, forever. */
|
|
30
|
+
export function createRng(seed: number): Rng {
|
|
31
|
+
if (!Number.isInteger(seed)) {
|
|
32
|
+
throw new ReservingError("BAD_SEED", "The RNG seed must be an integer");
|
|
33
|
+
}
|
|
34
|
+
let a = seed >>> 0;
|
|
35
|
+
const next = (): number => {
|
|
36
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
37
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
38
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
39
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let spare: number | null = null;
|
|
43
|
+
const normal = (): number => {
|
|
44
|
+
if (spare !== null) {
|
|
45
|
+
const v = spare;
|
|
46
|
+
spare = null;
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
// Box-Muller; u clamped away from 0 so log stays finite.
|
|
50
|
+
let u = next();
|
|
51
|
+
if (u < 1e-12) u = 1e-12;
|
|
52
|
+
const v = next();
|
|
53
|
+
const r = Math.sqrt(-2 * Math.log(u));
|
|
54
|
+
const theta = 2 * Math.PI * v;
|
|
55
|
+
spare = r * Math.sin(theta);
|
|
56
|
+
return r * Math.cos(theta);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const gamma = (shape: number): number => {
|
|
60
|
+
if (!isNum(shape) || shape <= 0) {
|
|
61
|
+
throw new ReservingError("BAD_SHAPE", "Gamma shape must be a positive number");
|
|
62
|
+
}
|
|
63
|
+
if (shape < 1) {
|
|
64
|
+
// Ahrens-Dieter boost: G(a) = G(a+1) * U^(1/a).
|
|
65
|
+
const u = Math.max(next(), 1e-12);
|
|
66
|
+
return gamma(shape + 1) * Math.pow(u, 1 / shape);
|
|
67
|
+
}
|
|
68
|
+
// Marsaglia & Tsang (2000).
|
|
69
|
+
const d = shape - 1 / 3;
|
|
70
|
+
const c = 1 / Math.sqrt(9 * d);
|
|
71
|
+
for (;;) {
|
|
72
|
+
let x: number;
|
|
73
|
+
let v: number;
|
|
74
|
+
do {
|
|
75
|
+
x = normal();
|
|
76
|
+
v = 1 + c * x;
|
|
77
|
+
} while (v <= 0);
|
|
78
|
+
v = v * v * v;
|
|
79
|
+
const u = next();
|
|
80
|
+
if (u < 1 - 0.0331 * x * x * x * x) return d * v;
|
|
81
|
+
if (Math.log(Math.max(u, 1e-300)) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return { next, normal, gamma };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The percentiles every stochastic summary reports, as fractions. */
|
|
89
|
+
export const STANDARD_PERCENTILES = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99] as const;
|
|
90
|
+
|
|
91
|
+
export interface StochasticSummary {
|
|
92
|
+
mean: number;
|
|
93
|
+
/** Sample standard deviation (n - 1). */
|
|
94
|
+
sd: number;
|
|
95
|
+
/** sd / |mean|; null when the mean is 0. */
|
|
96
|
+
cv: number | null;
|
|
97
|
+
/** Keyed "p50", "p75", ... per STANDARD_PERCENTILES; linear interpolation. */
|
|
98
|
+
percentiles: Record<string, number>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface StochasticOriginResult {
|
|
102
|
+
origin: string;
|
|
103
|
+
summary: StochasticSummary;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface StochasticResult {
|
|
107
|
+
/** What the simulated quantity IS (e.g. "unpaid", "ultimate", "cdr"). */
|
|
108
|
+
quantity: string;
|
|
109
|
+
seed: number;
|
|
110
|
+
nSims: number;
|
|
111
|
+
total: StochasticSummary;
|
|
112
|
+
byOrigin: StochasticOriginResult[];
|
|
113
|
+
/** Total-level simulated values, ascending, for caller-side percentiles/plots. */
|
|
114
|
+
totalSamples: number[];
|
|
115
|
+
warnings: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Linear-interpolated percentile of a SORTED ascending sample. */
|
|
119
|
+
export function percentileOfSorted(sorted: number[], p: number): number {
|
|
120
|
+
if (sorted.length === 0) {
|
|
121
|
+
throw new ReservingError("NO_DATA", "Cannot take a percentile of an empty sample");
|
|
122
|
+
}
|
|
123
|
+
if (!isNum(p) || p < 0 || p > 1) {
|
|
124
|
+
throw new ReservingError("BAD_PERCENTILE", `Percentile must be in [0, 1] (got ${p})`);
|
|
125
|
+
}
|
|
126
|
+
const idx = p * (sorted.length - 1);
|
|
127
|
+
const lo = Math.floor(idx);
|
|
128
|
+
const hi = Math.ceil(idx);
|
|
129
|
+
if (lo === hi) return sorted[lo]!;
|
|
130
|
+
return sorted[lo]! + (idx - lo) * (sorted[hi]! - sorted[lo]!);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Summarizes a sample (sorts a copy; the input is not mutated). */
|
|
134
|
+
export function summarizeSample(values: number[]): StochasticSummary {
|
|
135
|
+
if (values.length < 2) {
|
|
136
|
+
throw new ReservingError("TOO_SMALL", "A stochastic summary needs at least two simulations");
|
|
137
|
+
}
|
|
138
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
139
|
+
const n = sorted.length;
|
|
140
|
+
let sum = 0;
|
|
141
|
+
for (const v of sorted) sum += v;
|
|
142
|
+
const mean = sum / n;
|
|
143
|
+
let ss = 0;
|
|
144
|
+
for (const v of sorted) ss += (v - mean) ** 2;
|
|
145
|
+
const sd = Math.sqrt(ss / (n - 1));
|
|
146
|
+
const percentiles: Record<string, number> = {};
|
|
147
|
+
for (const p of STANDARD_PERCENTILES) {
|
|
148
|
+
percentiles[`p${Math.round(p * 100)}`] = percentileOfSorted(sorted, p);
|
|
149
|
+
}
|
|
150
|
+
return { mean, sd, cv: mean === 0 ? null : sd / Math.abs(mean), percentiles };
|
|
151
|
+
}
|
package/src/tail.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { TailFit, TailMethod } from "./types.js";
|
|
2
|
+
import { isNum, ols } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tail factor curve fitting per Sherman (1984) and Boor (2006).
|
|
6
|
+
*
|
|
7
|
+
* - Exponential decay: ln(LDF_j - 1) is linear in the development period index.
|
|
8
|
+
* - Inverse power: ln(LDF_j - 1) is linear in ln(period index).
|
|
9
|
+
*
|
|
10
|
+
* The fit runs on the SELECTED age-to-age factors. Guards:
|
|
11
|
+
* - fewer than MIN_POINTS usable points (factor > 1) -> invalid;
|
|
12
|
+
* - non-negative slope (growth instead of decay) -> invalid;
|
|
13
|
+
* - extrapolation is capped in horizon and in total tail size, with a loud
|
|
14
|
+
* warning when the cap binds (inverse power with slope > -1 diverges).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MIN_POINTS = 3;
|
|
18
|
+
/** Extrapolate at most this many development periods beyond the last age. */
|
|
19
|
+
const MAX_HORIZON = 200;
|
|
20
|
+
/** Stop extrapolating once the incremental factor is this close to 1. */
|
|
21
|
+
const CONVERGENCE_EPS = 1e-7;
|
|
22
|
+
/** A fitted tail beyond this bound is treated as divergent. */
|
|
23
|
+
const MAX_TAIL = 5;
|
|
24
|
+
|
|
25
|
+
export interface FitTailOptions {
|
|
26
|
+
method: TailMethod;
|
|
27
|
+
/**
|
|
28
|
+
* Selected LDFs by development column (from ages[j] to ages[j+1]);
|
|
29
|
+
* null entries are skipped.
|
|
30
|
+
*/
|
|
31
|
+
selectedLdfs: (number | null)[];
|
|
32
|
+
/** Cap on extrapolated development periods (default 200). */
|
|
33
|
+
maxHorizon?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fitTail(options: FitTailOptions): TailFit {
|
|
37
|
+
const { method, selectedLdfs } = options;
|
|
38
|
+
const maxHorizon = options.maxHorizon ?? MAX_HORIZON;
|
|
39
|
+
const warnings: string[] = [];
|
|
40
|
+
|
|
41
|
+
// x = 1-based development period index of each factor; y = ln(f - 1).
|
|
42
|
+
const xs: number[] = [];
|
|
43
|
+
const ys: number[] = [];
|
|
44
|
+
let skippedAtOrBelowOne = 0;
|
|
45
|
+
for (let j = 0; j < selectedLdfs.length; j++) {
|
|
46
|
+
const f = selectedLdfs[j] ?? null;
|
|
47
|
+
if (!isNum(f)) continue;
|
|
48
|
+
if (f <= 1) {
|
|
49
|
+
skippedAtOrBelowOne++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const idx = j + 1;
|
|
53
|
+
xs.push(method === "exponentialDecay" ? idx : Math.log(idx));
|
|
54
|
+
ys.push(Math.log(f - 1));
|
|
55
|
+
}
|
|
56
|
+
if (skippedAtOrBelowOne > 0) {
|
|
57
|
+
warnings.push(
|
|
58
|
+
`${skippedAtOrBelowOne} selected factor(s) at or below 1.000 were excluded from the fit (ln(f-1) undefined)`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const invalid = (message: string): TailFit => ({
|
|
63
|
+
method,
|
|
64
|
+
intercept: NaN,
|
|
65
|
+
slope: NaN,
|
|
66
|
+
rSquared: NaN,
|
|
67
|
+
nPoints: xs.length,
|
|
68
|
+
extrapolatedFactors: [],
|
|
69
|
+
tailFactor: 1,
|
|
70
|
+
valid: false,
|
|
71
|
+
warnings: [...warnings, message],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (xs.length < MIN_POINTS) {
|
|
75
|
+
return invalid(
|
|
76
|
+
`Only ${xs.length} usable point(s); at least ${MIN_POINTS} factors above 1.000 are required to fit a tail curve`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const fit = ols(xs, ys);
|
|
80
|
+
if (!fit) return invalid("Degenerate regression (no variation in development ages)");
|
|
81
|
+
if (fit.slope >= 0) {
|
|
82
|
+
return invalid(
|
|
83
|
+
"Fitted curve grows with age instead of decaying; the selected factors do not support a tail extrapolation with this model",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Extrapolate incremental factors beyond the last SELECTED column. Trailing
|
|
88
|
+
// null selections carry no development of their own (chain ladder treats
|
|
89
|
+
// them as 1.000), so the extrapolation must start right after the last
|
|
90
|
+
// non-null selection or the curve's predicted factors for those columns
|
|
91
|
+
// would silently vanish from the tail.
|
|
92
|
+
let lastIdx = 0; // 1-based index of the last non-null selection
|
|
93
|
+
for (let j = selectedLdfs.length - 1; j >= 0; j--) {
|
|
94
|
+
if (isNum(selectedLdfs[j] ?? null)) {
|
|
95
|
+
lastIdx = j + 1;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (lastIdx < selectedLdfs.length) {
|
|
100
|
+
warnings.push(
|
|
101
|
+
`The last ${selectedLdfs.length - lastIdx} development column(s) have no selected factor; the fitted tail covers them via the curve (chain ladder would otherwise treat them as 1.000)`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const extrapolatedFactors: number[] = [];
|
|
105
|
+
let tail = 1;
|
|
106
|
+
let converged = false;
|
|
107
|
+
for (let step = 1; step <= maxHorizon; step++) {
|
|
108
|
+
const idx = lastIdx + step;
|
|
109
|
+
const x = method === "exponentialDecay" ? idx : Math.log(idx);
|
|
110
|
+
const f = 1 + Math.exp(fit.intercept + fit.slope * x);
|
|
111
|
+
extrapolatedFactors.push(f);
|
|
112
|
+
tail *= f;
|
|
113
|
+
if (tail > MAX_TAIL) {
|
|
114
|
+
return invalid(
|
|
115
|
+
`Extrapolated tail exceeded ${MAX_TAIL.toFixed(1)} after ${step} periods; the fit is divergent (inverse power with slope > -1 has an unbounded product). Enter a tail judgmentally instead.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (f - 1 < CONVERGENCE_EPS) {
|
|
119
|
+
converged = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!converged) {
|
|
124
|
+
warnings.push(
|
|
125
|
+
`Extrapolation stopped at the ${maxHorizon}-period horizon before the factors converged to 1.000; the tail is truncated and understated`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (fit.rSquared < 0.8) {
|
|
130
|
+
warnings.push(
|
|
131
|
+
`Fit quality is weak (R-squared ${fit.rSquared.toFixed(3)}); treat this tail as indicative only`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
method,
|
|
137
|
+
intercept: fit.intercept,
|
|
138
|
+
slope: fit.slope,
|
|
139
|
+
rSquared: fit.rSquared,
|
|
140
|
+
nPoints: fit.n,
|
|
141
|
+
extrapolatedFactors,
|
|
142
|
+
tailFactor: tail,
|
|
143
|
+
valid: true,
|
|
144
|
+
warnings,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Fits both supported curves so the user can compare them side by side. */
|
|
149
|
+
export function fitAllTails(
|
|
150
|
+
selectedLdfs: (number | null)[],
|
|
151
|
+
): { exponentialDecay: TailFit; inversePower: TailFit } {
|
|
152
|
+
return {
|
|
153
|
+
exponentialDecay: fitTail({ method: "exponentialDecay", selectedLdfs }),
|
|
154
|
+
inversePower: fitTail({ method: "inversePower", selectedLdfs }),
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/trend.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
import { isNum, ols } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trend machinery: log-linear regressions on year-indexed actuarial series
|
|
6
|
+
* (ultimate frequency, severity, pure premium), with the same menu
|
|
7
|
+
* discipline as the LDF exhibit - several fitted windows plus judgment.
|
|
8
|
+
*
|
|
9
|
+
* ln(y) = a + b·t fitted by ordinary least squares; the annual trend is
|
|
10
|
+
* e^b - 1. R-squared is reported so thin or noisy series are judged, not
|
|
11
|
+
* trusted blindly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface TrendPoint {
|
|
15
|
+
/** Origin year (the regression x, centered internally). */
|
|
16
|
+
year: number;
|
|
17
|
+
/** The series value (must be positive to enter the log fit). */
|
|
18
|
+
value: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TrendFit {
|
|
22
|
+
key: string;
|
|
23
|
+
label: string;
|
|
24
|
+
/** Annual trend rate, e.g. 0.05 = +5%/yr; null when the window can't fit. */
|
|
25
|
+
annualRate: number | null;
|
|
26
|
+
rSquared: number | null;
|
|
27
|
+
nPoints: number;
|
|
28
|
+
/** Yearly values the window actually used. */
|
|
29
|
+
usedYears: number[];
|
|
30
|
+
warnings: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TrendAnalysis {
|
|
34
|
+
points: TrendPoint[];
|
|
35
|
+
fits: TrendFit[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fitWindow(
|
|
39
|
+
points: { year: number; value: number }[],
|
|
40
|
+
key: string,
|
|
41
|
+
label: string,
|
|
42
|
+
): TrendFit {
|
|
43
|
+
const warnings: string[] = [];
|
|
44
|
+
if (points.length < 3) {
|
|
45
|
+
return {
|
|
46
|
+
key,
|
|
47
|
+
label,
|
|
48
|
+
annualRate: null,
|
|
49
|
+
rSquared: null,
|
|
50
|
+
nPoints: points.length,
|
|
51
|
+
usedYears: points.map((p) => p.year),
|
|
52
|
+
warnings: ["Fewer than 3 usable points; no fit"],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const xs = points.map((p) => p.year);
|
|
56
|
+
const ys = points.map((p) => Math.log(p.value));
|
|
57
|
+
// n >= 3 here, so a null fit can only mean zero variation in years.
|
|
58
|
+
const fit = ols(xs, ys);
|
|
59
|
+
if (fit === null) {
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
label,
|
|
63
|
+
annualRate: null,
|
|
64
|
+
rSquared: null,
|
|
65
|
+
nPoints: points.length,
|
|
66
|
+
usedYears: xs,
|
|
67
|
+
warnings: ["No variation in years; no fit"],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (points.length < 5) {
|
|
71
|
+
warnings.push(`Only ${points.length} points; the fitted trend is volatile`);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
key,
|
|
75
|
+
label,
|
|
76
|
+
annualRate: Math.exp(fit.slope) - 1,
|
|
77
|
+
rSquared: fit.rSquared,
|
|
78
|
+
nPoints: points.length,
|
|
79
|
+
usedYears: xs,
|
|
80
|
+
warnings,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fits the standard windows over a year-indexed series: all years, last 5
|
|
86
|
+
* YEARS, last 3 YEARS, and all years excluding the highest and lowest values
|
|
87
|
+
* (the ex-hi-lo medial convention). Windows are sized in POINTS PER YEAR so
|
|
88
|
+
* a quarterly series' "Last 5 years" really spans 5 years (20 quarters), not
|
|
89
|
+
* 5 points. Non-positive and missing values are excluded from every window
|
|
90
|
+
* (a log fit cannot see them), with a warning.
|
|
91
|
+
*/
|
|
92
|
+
export function analyzeTrend(points: TrendPoint[], pointsPerYear = 1): TrendAnalysis {
|
|
93
|
+
const usable = points
|
|
94
|
+
.filter((p): p is { year: number; value: number } => isNum(p.value) && p.value! > 0)
|
|
95
|
+
.sort((a, b) => a.year - b.year);
|
|
96
|
+
const excluded = points.length - usable.length;
|
|
97
|
+
|
|
98
|
+
const fits: TrendFit[] = [];
|
|
99
|
+
const base = (key: string, label: string, pts: { year: number; value: number }[]) => {
|
|
100
|
+
const fit = fitWindow(pts, key, label);
|
|
101
|
+
if (excluded > 0) {
|
|
102
|
+
fit.warnings.push(`${excluded} missing/non-positive year(s) excluded from the series`);
|
|
103
|
+
}
|
|
104
|
+
fits.push(fit);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const ppy = Math.max(1, Math.round(pointsPerYear));
|
|
108
|
+
base("all", "All years", usable);
|
|
109
|
+
base("last5", "Last 5 years", usable.slice(-5 * ppy));
|
|
110
|
+
base("last3", "Last 3 years", usable.slice(-3 * ppy));
|
|
111
|
+
if (usable.length >= 5) {
|
|
112
|
+
const sortedByValue = [...usable].sort((a, b) => a.value - b.value);
|
|
113
|
+
const hi = sortedByValue[sortedByValue.length - 1]!;
|
|
114
|
+
const lo = sortedByValue[0]!;
|
|
115
|
+
base(
|
|
116
|
+
"exhilo",
|
|
117
|
+
"Ex high/low",
|
|
118
|
+
usable.filter((p) => p !== hi && p !== lo),
|
|
119
|
+
);
|
|
120
|
+
} else {
|
|
121
|
+
fits.push({
|
|
122
|
+
key: "exhilo",
|
|
123
|
+
label: "Ex high/low",
|
|
124
|
+
annualRate: null,
|
|
125
|
+
rSquared: null,
|
|
126
|
+
nPoints: usable.length,
|
|
127
|
+
usedYears: [],
|
|
128
|
+
warnings: ["Needs at least 5 usable points"],
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { points, fits };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Trend a value from one year's cost level to another's:
|
|
137
|
+
* value × (1 + rate)^(toYear − fromYear). Midpoint-to-midpoint conventions
|
|
138
|
+
* are the caller's responsibility (whole years in, whole years out).
|
|
139
|
+
*/
|
|
140
|
+
export function trendValue(
|
|
141
|
+
value: number,
|
|
142
|
+
rate: number,
|
|
143
|
+
fromYear: number,
|
|
144
|
+
toYear: number,
|
|
145
|
+
): number {
|
|
146
|
+
if (!isNum(rate) || rate <= -1) {
|
|
147
|
+
throw new ReservingError("BAD_TREND", "A trend rate must be a number greater than -100%");
|
|
148
|
+
}
|
|
149
|
+
return value * Math.pow(1 + rate, toYear - fromYear);
|
|
150
|
+
}
|