@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,337 @@
|
|
|
1
|
+
import type { Triangle } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { isNum, lastObservedIndex } from "./util.js";
|
|
4
|
+
import { cumulativeToIncremental } from "./triangleAlgebra.js";
|
|
5
|
+
import {
|
|
6
|
+
createRng,
|
|
7
|
+
summarizeSample,
|
|
8
|
+
type StochasticResult,
|
|
9
|
+
} from "./stochastic.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Over-dispersed Poisson (ODP) bootstrap of the chain ladder.
|
|
13
|
+
*
|
|
14
|
+
* Ground truth (England & Verrall 1999/2002; Shapland, CAS Monograph No. 4):
|
|
15
|
+
* - The cross-classified ODP GLM's fitted values reproduce the all-year
|
|
16
|
+
* volume-weighted chain ladder EXACTLY. In practice the fitted past
|
|
17
|
+
* incrementals come from the backwards recursion: anchor each row's
|
|
18
|
+
* latest observed cumulative, divide back through the volume-weighted
|
|
19
|
+
* factors, and difference.
|
|
20
|
+
* - Unscaled Pearson residuals r = (q - m) / sqrt(m) on incrementals; the
|
|
21
|
+
* scale parameter is phi = sum(r^2) / (n - p) with p = 2I - 1 parameters for
|
|
22
|
+
* an I-origin triangle and n = the incrementals that CONTRIBUTE a residual.
|
|
23
|
+
* On a well-behaved triangle that is every observed incremental, but a
|
|
24
|
+
* non-positive fitted mean leaves the residual undefined: such a cell is
|
|
25
|
+
* absent from the numerator, so counting it in the denominator would divide
|
|
26
|
+
* the sum of squares by degrees of freedom it never earned.
|
|
27
|
+
* - Resampling: draw residuals with replacement onto the fitted past
|
|
28
|
+
* (q* = m + r sqrt(m)), cumulate, refit volume-weighted factors, project
|
|
29
|
+
* each future incremental, and add process variance by sampling
|
|
30
|
+
* Gamma(mean m_f, variance phi m_f). Residuals are inflated by
|
|
31
|
+
* sqrt(n / (n - p)) first (the standard small-sample bias adjustment) —
|
|
32
|
+
* controllable via options.
|
|
33
|
+
* - Structural zero residuals (cells the fit reproduces exactly by
|
|
34
|
+
* construction, e.g. the corners) are excluded from the resampling pool.
|
|
35
|
+
*
|
|
36
|
+
* The GLM-mean == chain-ladder identity is the method's own validation
|
|
37
|
+
* hook: `odpFit(tri).reserveByOrigin` must tie to the volume-weighted
|
|
38
|
+
* chain ladder to floating-point precision, and the test suite pins it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export interface OdpFit {
|
|
42
|
+
/** Volume-weighted all-year factors, one per development interval. */
|
|
43
|
+
factors: number[];
|
|
44
|
+
/** Fitted past incrementals m_ij (null where unobserved). */
|
|
45
|
+
fittedIncrementals: (number | null)[][];
|
|
46
|
+
/** Unscaled Pearson residuals (null where unobserved or structurally zero). */
|
|
47
|
+
residuals: (number | null)[][];
|
|
48
|
+
/** Future incremental means per cell (null where already observed). */
|
|
49
|
+
futureMeans: (number | null)[][];
|
|
50
|
+
/** Expected unpaid per origin (== volume-weighted chain ladder reserve). */
|
|
51
|
+
reserveByOrigin: { origin: string; reserve: number }[];
|
|
52
|
+
phi: number;
|
|
53
|
+
/** Incrementals contributing a residual — the dispersion's numerator count. */
|
|
54
|
+
n: number;
|
|
55
|
+
/** Fitted parameters, 2I - 1 for an I-origin triangle. */
|
|
56
|
+
p: number;
|
|
57
|
+
/** Residuals actually in the resampling pool (structural zeros excluded). */
|
|
58
|
+
poolSize: number;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Volume-weighted all-year factors (the ODP GLM's implied development). */
|
|
63
|
+
function volumeWeightedFactors(tri: Triangle): number[] {
|
|
64
|
+
const K = tri.ages.length;
|
|
65
|
+
const out: number[] = [];
|
|
66
|
+
for (let j = 0; j < K - 1; j++) {
|
|
67
|
+
let num = 0;
|
|
68
|
+
let den = 0;
|
|
69
|
+
for (let i = 0; i < tri.origins.length; i++) {
|
|
70
|
+
const c0 = tri.values[i]![j] ?? null;
|
|
71
|
+
const c1 = tri.values[i]![j + 1] ?? null;
|
|
72
|
+
if (isNum(c0) && isNum(c1) && c0 > 0) {
|
|
73
|
+
num += c1;
|
|
74
|
+
den += c0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (den <= 0) {
|
|
78
|
+
throw new ReservingError(
|
|
79
|
+
"NO_FACTOR",
|
|
80
|
+
`Development column ${tri.ages[j]}-${tri.ages[j + 1]} has no usable factors for the ODP fit`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
out.push(num / den);
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Fits the ODP cross-classified model via the chain-ladder equivalence. */
|
|
89
|
+
export function odpFit(tri: Triangle): OdpFit {
|
|
90
|
+
const warnings: string[] = [];
|
|
91
|
+
const I = tri.origins.length;
|
|
92
|
+
const K = tri.ages.length;
|
|
93
|
+
if (I < 3 || K < 3) {
|
|
94
|
+
throw new ReservingError("TOO_SMALL", "The ODP bootstrap needs at least a 3x3 triangle");
|
|
95
|
+
}
|
|
96
|
+
const factors = volumeWeightedFactors(tri);
|
|
97
|
+
const incr = cumulativeToIncremental(tri);
|
|
98
|
+
|
|
99
|
+
// Backwards recursion for fitted past cumulatives, anchored at each row's
|
|
100
|
+
// latest observed diagonal.
|
|
101
|
+
const fittedCum: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
|
|
102
|
+
const latestIdx: number[] = [];
|
|
103
|
+
for (let i = 0; i < I; i++) {
|
|
104
|
+
const d = lastObservedIndex(tri.values[i]!);
|
|
105
|
+
latestIdx.push(d);
|
|
106
|
+
if (d < 0) {
|
|
107
|
+
warnings.push(`Origin ${tri.origins[i]} has no observed cells; excluded from the fit`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const anchor = tri.values[i]![d]!;
|
|
111
|
+
fittedCum[i]![d] = anchor;
|
|
112
|
+
for (let j = d - 1; j >= 0; j--) {
|
|
113
|
+
fittedCum[i]![j] = fittedCum[i]![j + 1]! / factors[j]!;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fitted past incrementals.
|
|
118
|
+
const fittedIncrementals: (number | null)[][] = fittedCum.map((row, i) => {
|
|
119
|
+
const d = latestIdx[i]!;
|
|
120
|
+
return row.map((v, j) => {
|
|
121
|
+
if (j > d || !isNum(v)) return null;
|
|
122
|
+
if (j === 0) return v;
|
|
123
|
+
return v - row[j - 1]!;
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Pearson residuals; structural zeros (|q - m| ~ 0 at machine precision
|
|
128
|
+
// where the fit reproduces the cell by construction) leave the pool.
|
|
129
|
+
const residuals: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
|
|
130
|
+
let n = 0;
|
|
131
|
+
let sumSq = 0;
|
|
132
|
+
let pool = 0;
|
|
133
|
+
let negativeFitted = 0;
|
|
134
|
+
for (let i = 0; i < I; i++) {
|
|
135
|
+
for (let j = 0; j <= latestIdx[i]!; j++) {
|
|
136
|
+
const q = incr.values[i]![j] ?? null;
|
|
137
|
+
const m = fittedIncrementals[i]![j] ?? null;
|
|
138
|
+
if (!isNum(q) || !isNum(m)) continue;
|
|
139
|
+
if (m <= 0) {
|
|
140
|
+
// The Pearson residual (q - m)/sqrt(m) is undefined for a non-positive
|
|
141
|
+
// fitted mean, so this cell contributes nothing to `sumSq` — and must
|
|
142
|
+
// therefore not count toward the degrees of freedom that divide it.
|
|
143
|
+
// Counting it would inflate `n - p`, understating phi and with it the
|
|
144
|
+
// process variance, which is the wrong direction for a reserve range.
|
|
145
|
+
negativeFitted++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
n++;
|
|
149
|
+
const r = (q - m) / Math.sqrt(m);
|
|
150
|
+
sumSq += r * r;
|
|
151
|
+
if (Math.abs(r) > 1e-10) {
|
|
152
|
+
residuals[i]![j] = r;
|
|
153
|
+
pool++;
|
|
154
|
+
} else {
|
|
155
|
+
residuals[i]![j] = 0; // structural zero: reported, not resampled
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (negativeFitted > 0) {
|
|
160
|
+
warnings.push(
|
|
161
|
+
`${negativeFitted} fitted incremental(s) are non-positive; their residuals are undefined and excluded (see Shapland on negative incrementals)`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const p = 2 * I - 1;
|
|
165
|
+
if (n <= p) {
|
|
166
|
+
throw new ReservingError(
|
|
167
|
+
"TOO_SMALL",
|
|
168
|
+
`The ODP fit has ${n} usable residual(s) for ${p} parameters; no degrees of ` +
|
|
169
|
+
`freedom remain to estimate dispersion` +
|
|
170
|
+
(negativeFitted > 0
|
|
171
|
+
? ` (${negativeFitted} cell(s) were excluded for a non-positive fitted mean)`
|
|
172
|
+
: ""),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const phi = sumSq / (n - p);
|
|
176
|
+
|
|
177
|
+
// Future incremental means from the fitted projection.
|
|
178
|
+
const futureMeans: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
|
|
179
|
+
const reserveByOrigin: { origin: string; reserve: number }[] = [];
|
|
180
|
+
for (let i = 0; i < I; i++) {
|
|
181
|
+
const d = latestIdx[i]!;
|
|
182
|
+
if (d < 0) continue;
|
|
183
|
+
let cum = tri.values[i]![d]!;
|
|
184
|
+
let reserve = 0;
|
|
185
|
+
for (let j = d + 1; j < K; j++) {
|
|
186
|
+
const next = cum * factors[j - 1]!;
|
|
187
|
+
const m = next - cum;
|
|
188
|
+
futureMeans[i]![j] = m;
|
|
189
|
+
reserve += m;
|
|
190
|
+
cum = next;
|
|
191
|
+
}
|
|
192
|
+
reserveByOrigin.push({ origin: tri.origins[i]!, reserve });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
factors,
|
|
197
|
+
fittedIncrementals,
|
|
198
|
+
residuals,
|
|
199
|
+
futureMeans,
|
|
200
|
+
reserveByOrigin,
|
|
201
|
+
phi,
|
|
202
|
+
n,
|
|
203
|
+
p,
|
|
204
|
+
poolSize: pool,
|
|
205
|
+
warnings,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface OdpBootstrapOptions {
|
|
210
|
+
nSims: number;
|
|
211
|
+
seed: number;
|
|
212
|
+
/** Inflate residuals by sqrt(n/(n-p)) before resampling (default true). */
|
|
213
|
+
biasAdjust?: boolean;
|
|
214
|
+
/** Add ODP process variance via gamma sampling (default true). */
|
|
215
|
+
processVariance?: boolean;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface OdpBootstrapResult extends StochasticResult {
|
|
219
|
+
method: "odpBootstrap";
|
|
220
|
+
fit: OdpFit;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The ODP bootstrap. Same seed + same triangle = same result, bit for bit. */
|
|
224
|
+
export function runOdpBootstrap(tri: Triangle, options: OdpBootstrapOptions): OdpBootstrapResult {
|
|
225
|
+
const { nSims, seed } = options;
|
|
226
|
+
if (!Number.isInteger(nSims) || nSims < 100) {
|
|
227
|
+
throw new ReservingError("TOO_SMALL", "The bootstrap needs an integer nSims >= 100");
|
|
228
|
+
}
|
|
229
|
+
const biasAdjust = options.biasAdjust ?? true;
|
|
230
|
+
const processVariance = options.processVariance ?? true;
|
|
231
|
+
|
|
232
|
+
const fit = odpFit(tri);
|
|
233
|
+
const warnings = [...fit.warnings];
|
|
234
|
+
const I = tri.origins.length;
|
|
235
|
+
const K = tri.ages.length;
|
|
236
|
+
const rng = createRng(seed);
|
|
237
|
+
|
|
238
|
+
// Residual pool (bias-adjusted), flattened.
|
|
239
|
+
const inflate = biasAdjust ? Math.sqrt(fit.n / (fit.n - fit.p)) : 1;
|
|
240
|
+
const pool: number[] = [];
|
|
241
|
+
for (const row of fit.residuals) {
|
|
242
|
+
for (const r of row) {
|
|
243
|
+
if (isNum(r) && Math.abs(r) > 1e-10) pool.push(r * inflate);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (pool.length < 10) {
|
|
247
|
+
throw new ReservingError("TOO_SMALL", "Fewer than 10 usable residuals; bootstrap unreliable");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const latestIdx = tri.values.map((row) => lastObservedIndex(row));
|
|
251
|
+
let negativeFutureMeans = 0;
|
|
252
|
+
|
|
253
|
+
const totals: number[] = new Array(nSims).fill(0);
|
|
254
|
+
const perOrigin: number[][] = tri.origins.map(() => new Array(nSims).fill(0));
|
|
255
|
+
|
|
256
|
+
for (let b = 0; b < nSims; b++) {
|
|
257
|
+
// 1. Pseudo past incrementals -> pseudo cumulative triangle.
|
|
258
|
+
const pseudoCum: (number | null)[][] = tri.origins.map(() => new Array(K).fill(null));
|
|
259
|
+
for (let i = 0; i < I; i++) {
|
|
260
|
+
let running = 0;
|
|
261
|
+
for (let j = 0; j <= latestIdx[i]!; j++) {
|
|
262
|
+
const m = fit.fittedIncrementals[i]![j];
|
|
263
|
+
if (!isNum(m) || m <= 0) {
|
|
264
|
+
// Cells without a usable fitted value keep their mean contribution.
|
|
265
|
+
running += isNum(m) ? m : 0;
|
|
266
|
+
} else {
|
|
267
|
+
const r = pool[Math.floor(rng.next() * pool.length)]!;
|
|
268
|
+
running += m + r * Math.sqrt(m);
|
|
269
|
+
}
|
|
270
|
+
pseudoCum[i]![j] = running;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const pseudo: Triangle = {
|
|
274
|
+
kind: tri.kind,
|
|
275
|
+
origins: tri.origins,
|
|
276
|
+
ages: tri.ages,
|
|
277
|
+
values: pseudoCum,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// 2. Refit and project; 3. process variance on each future incremental.
|
|
281
|
+
let factors: number[];
|
|
282
|
+
try {
|
|
283
|
+
factors = volumeWeightedFactors(pseudo);
|
|
284
|
+
} catch {
|
|
285
|
+
// A pathological resample (non-positive column volume) is discarded by
|
|
286
|
+
// reusing the fitted factors; counted and warned once at the end.
|
|
287
|
+
factors = fit.factors;
|
|
288
|
+
negativeFutureMeans++;
|
|
289
|
+
}
|
|
290
|
+
for (let i = 0; i < I; i++) {
|
|
291
|
+
const d = latestIdx[i]!;
|
|
292
|
+
if (d < 0) continue;
|
|
293
|
+
let cum = pseudoCum[i]![d]!;
|
|
294
|
+
let reserve = 0;
|
|
295
|
+
for (let j = d + 1; j < K; j++) {
|
|
296
|
+
const next = cum * factors[j - 1]!;
|
|
297
|
+
let m = next - cum;
|
|
298
|
+
if (processVariance) {
|
|
299
|
+
if (m > 0) {
|
|
300
|
+
// Gamma with mean m, variance phi m.
|
|
301
|
+
m = rng.gamma(m / fit.phi) * fit.phi;
|
|
302
|
+
} else if (m < 0) {
|
|
303
|
+
negativeFutureMeans++;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
reserve += m;
|
|
307
|
+
// The projection chain advances on the mean path; process noise is
|
|
308
|
+
// per-cell around the mean (England & Verrall 2002, Appendix 3).
|
|
309
|
+
cum = next;
|
|
310
|
+
}
|
|
311
|
+
perOrigin[i]![b] = reserve;
|
|
312
|
+
totals[b]! += reserve;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (negativeFutureMeans > 0) {
|
|
316
|
+
warnings.push(
|
|
317
|
+
`${negativeFutureMeans} simulated future incremental(s)/resample(s) had non-positive means; added deterministically without process variance (documented simplification for downward development)`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const byOrigin = tri.origins
|
|
322
|
+
.map((origin, i) => ({ origin, samples: perOrigin[i]! }))
|
|
323
|
+
.filter((e) => latestIdx[tri.origins.indexOf(e.origin)]! >= 0 && e.samples.some((v) => v !== 0))
|
|
324
|
+
.map((e) => ({ origin: e.origin, summary: summarizeSample(e.samples) }));
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
method: "odpBootstrap",
|
|
328
|
+
quantity: "unpaid",
|
|
329
|
+
seed,
|
|
330
|
+
nSims,
|
|
331
|
+
total: summarizeSample(totals),
|
|
332
|
+
byOrigin,
|
|
333
|
+
totalSamples: [...totals].sort((a, b) => a - b),
|
|
334
|
+
fit,
|
|
335
|
+
warnings,
|
|
336
|
+
};
|
|
337
|
+
}
|
package/src/onlevel.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
import { isNum } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parallelogram-method premium on-leveling (Werner & Modlin ch. 5).
|
|
6
|
+
*
|
|
7
|
+
* Assumptions, stated plainly: policies carry an ANNUAL term, are written
|
|
8
|
+
* uniformly through time, and premium earns evenly over the policy term. A
|
|
9
|
+
* rate change applies to policies WRITTEN on or after its effective date.
|
|
10
|
+
* Under those assumptions the share of a calendar period's EARNED premium
|
|
11
|
+
* sitting at each historical rate level is a piece of parallelogram
|
|
12
|
+
* geometry, computed here exactly (piecewise-linear integration, no grids).
|
|
13
|
+
*
|
|
14
|
+
* The on-level factor for a period = current cumulative rate level divided
|
|
15
|
+
* by the average rate level earned in that period.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface RateChange {
|
|
19
|
+
/** ISO date the change takes effect (applies to policies written on/after it). */
|
|
20
|
+
effectiveDate: string;
|
|
21
|
+
/** Rate change, e.g. 0.05 = +5%. */
|
|
22
|
+
change: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface OnLevelRow {
|
|
26
|
+
origin: string;
|
|
27
|
+
/** Average relative rate level earned in the period (1 = initial level). */
|
|
28
|
+
averageRateLevel: number;
|
|
29
|
+
/** currentLevel / averageRateLevel. */
|
|
30
|
+
onLevelFactor: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface OnLevelResult {
|
|
34
|
+
rows: OnLevelRow[];
|
|
35
|
+
/** Cumulative rate level after all changes (1 = initial level). */
|
|
36
|
+
currentLevel: number;
|
|
37
|
+
warnings: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ISO date -> fractional year (2021-07-01 -> ~2021.5). */
|
|
41
|
+
function dateToYearFraction(iso: string): number {
|
|
42
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
43
|
+
if (!m) {
|
|
44
|
+
throw new ReservingError("BAD_DATE", `Not an ISO date: ${iso}`);
|
|
45
|
+
}
|
|
46
|
+
const year = Number(m[1]);
|
|
47
|
+
const start = Date.UTC(year, 0, 1);
|
|
48
|
+
const next = Date.UTC(year + 1, 0, 1);
|
|
49
|
+
const t = Date.UTC(year, Number(m[2]) - 1, Number(m[3]));
|
|
50
|
+
return year + (t - start) / (next - start);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Origin label -> [start, end) in fractional years ("2021" or "2021Q3"). */
|
|
54
|
+
function originInterval(origin: string): [number, number] {
|
|
55
|
+
const year = Number(origin.slice(0, 4));
|
|
56
|
+
if (!Number.isInteger(year)) {
|
|
57
|
+
throw new ReservingError("BAD_ORIGIN", `Cannot parse an origin period from "${origin}"`);
|
|
58
|
+
}
|
|
59
|
+
const qMatch = /Q([1-4])$/.exec(origin);
|
|
60
|
+
if (qMatch) {
|
|
61
|
+
const q = Number(qMatch[1]);
|
|
62
|
+
return [year + (q - 1) / 4, year + q / 4];
|
|
63
|
+
}
|
|
64
|
+
return [year, year + 1];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Earned-premium density of the period [p0, p1) by policy WRITTEN time w:
|
|
69
|
+
* overlap of the earning interval [w, w+1] with [p0, p1). Integrated exactly
|
|
70
|
+
* over [a, b] (the density is piecewise linear with breakpoints at p0-1,
|
|
71
|
+
* p1-1, p0, p1).
|
|
72
|
+
*/
|
|
73
|
+
function earnedArea(p0: number, p1: number, a: number, b: number): number {
|
|
74
|
+
const lo = Math.max(a, p0 - 1);
|
|
75
|
+
const hi = Math.min(b, p1);
|
|
76
|
+
if (hi <= lo) return 0;
|
|
77
|
+
const density = (w: number): number =>
|
|
78
|
+
Math.max(0, Math.min(p1, w + 1) - Math.max(p0, w));
|
|
79
|
+
// Integrate exactly across the linear segments.
|
|
80
|
+
const breaks = [p0 - 1, p1 - 1, p0, p1]
|
|
81
|
+
.filter((x) => x > lo && x < hi)
|
|
82
|
+
.sort((x, y) => x - y);
|
|
83
|
+
const knots = [lo, ...breaks, hi];
|
|
84
|
+
let area = 0;
|
|
85
|
+
for (let i = 0; i + 1 < knots.length; i++) {
|
|
86
|
+
const x0 = knots[i]!;
|
|
87
|
+
const x1 = knots[i + 1]!;
|
|
88
|
+
area += ((density(x0) + density(x1)) / 2) * (x1 - x0); // exact: linear segment
|
|
89
|
+
}
|
|
90
|
+
return area;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* On-level factors per origin period from a rate-change history. Changes
|
|
95
|
+
* effective before all data simply set the base level; an empty history
|
|
96
|
+
* yields factors of exactly 1.
|
|
97
|
+
*/
|
|
98
|
+
export function parallelogramOnLevel(
|
|
99
|
+
origins: string[],
|
|
100
|
+
history: RateChange[],
|
|
101
|
+
): OnLevelResult {
|
|
102
|
+
const warnings: string[] = [];
|
|
103
|
+
for (const rc of history) {
|
|
104
|
+
if (!isNum(rc.change) || rc.change <= -1) {
|
|
105
|
+
throw new ReservingError(
|
|
106
|
+
"BAD_RATE_CHANGE",
|
|
107
|
+
"A rate change must be a number greater than -100%",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const changes = [...history]
|
|
112
|
+
.map((rc) => ({ at: dateToYearFraction(rc.effectiveDate), change: rc.change }))
|
|
113
|
+
.sort((a, b) => a.at - b.at);
|
|
114
|
+
|
|
115
|
+
// Rate-level eras: [eraStart_i, eraStart_{i+1}) at cumulative level_i.
|
|
116
|
+
const eras: { from: number; level: number }[] = [{ from: -Infinity, level: 1 }];
|
|
117
|
+
let level = 1;
|
|
118
|
+
for (const c of changes) {
|
|
119
|
+
level *= 1 + c.change;
|
|
120
|
+
eras.push({ from: c.at, level });
|
|
121
|
+
}
|
|
122
|
+
const currentLevel = level;
|
|
123
|
+
|
|
124
|
+
const rows: OnLevelRow[] = origins.map((origin) => {
|
|
125
|
+
const [p0, p1] = originInterval(origin);
|
|
126
|
+
const total = earnedArea(p0, p1, -Infinity, p1);
|
|
127
|
+
if (!(total > 0)) {
|
|
128
|
+
throw new ReservingError("BAD_ORIGIN", `Origin "${origin}" has no earnable area`);
|
|
129
|
+
}
|
|
130
|
+
let weighted = 0;
|
|
131
|
+
for (let i = 0; i < eras.length; i++) {
|
|
132
|
+
const from = eras[i]!.from;
|
|
133
|
+
const to = i + 1 < eras.length ? eras[i + 1]!.from : Infinity;
|
|
134
|
+
const share = earnedArea(p0, p1, from, to) / total;
|
|
135
|
+
weighted += share * eras[i]!.level;
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
origin,
|
|
139
|
+
averageRateLevel: weighted,
|
|
140
|
+
onLevelFactor: currentLevel / weighted,
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (changes.length > 0) {
|
|
145
|
+
const lastOriginEnd = Math.max(...origins.map((o) => originInterval(o)[1]));
|
|
146
|
+
const beyond = changes.filter((c) => c.at >= lastOriginEnd);
|
|
147
|
+
if (beyond.length > 0) {
|
|
148
|
+
warnings.push(
|
|
149
|
+
`${beyond.length} rate change(s) effective after the last origin period only move the current level (nothing historical to restate)`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { rows, currentLevel, warnings };
|
|
155
|
+
}
|
package/src/periods.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type QuarterNumber = 1 | 2 | 3 | 4;
|
|
4
|
+
|
|
5
|
+
export interface QuarterPeriod {
|
|
6
|
+
year: number;
|
|
7
|
+
quarter: QuarterNumber;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type QuarterFormat = "compact" | "hyphenated" | "quarter-first";
|
|
11
|
+
export type DevelopmentAgeConvention = "quarter-end-first-observation" | "elapsed";
|
|
12
|
+
|
|
13
|
+
const QUARTER_PATTERNS = [
|
|
14
|
+
/^(\d{4})Q([1-4])$/i,
|
|
15
|
+
/^(\d{4})-Q([1-4])$/i,
|
|
16
|
+
/^Q([1-4])\s+(\d{4})$/i,
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/** Parses `2024Q3`, `2024-Q3`, or `Q3 2024`; no lexical date guessing. */
|
|
20
|
+
export function parseQuarterPeriod(value: string): QuarterPeriod {
|
|
21
|
+
const text = value.trim();
|
|
22
|
+
for (let i = 0; i < QUARTER_PATTERNS.length; i++) {
|
|
23
|
+
const match = QUARTER_PATTERNS[i]!.exec(text);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
const quarterFirst = i === 2;
|
|
26
|
+
const year = Number(match[quarterFirst ? 2 : 1]);
|
|
27
|
+
const quarter = Number(match[quarterFirst ? 1 : 2]) as QuarterNumber;
|
|
28
|
+
if (Number.isSafeInteger(year) && year >= 1 && year <= 9999) return { year, quarter };
|
|
29
|
+
}
|
|
30
|
+
throw new ReservingError(
|
|
31
|
+
"BAD_ORIGIN",
|
|
32
|
+
`Quarter period must be YYYYQn, YYYY-Qn, or Qn YYYY with n from 1 to 4; got ${JSON.stringify(value)}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatQuarterPeriod(
|
|
37
|
+
period: QuarterPeriod,
|
|
38
|
+
format: QuarterFormat = "compact",
|
|
39
|
+
): string {
|
|
40
|
+
assertQuarterPeriod(period);
|
|
41
|
+
const year = String(period.year).padStart(4, "0");
|
|
42
|
+
if (format === "hyphenated") return `${year}-Q${period.quarter}`;
|
|
43
|
+
if (format === "quarter-first") return `Q${period.quarter} ${year}`;
|
|
44
|
+
return `${year}Q${period.quarter}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function compareQuarterPeriods(a: QuarterPeriod | string, b: QuarterPeriod | string): number {
|
|
48
|
+
const left = typeof a === "string" ? parseQuarterPeriod(a) : assertQuarterPeriod(a);
|
|
49
|
+
const right = typeof b === "string" ? parseQuarterPeriod(b) : assertQuarterPeriod(b);
|
|
50
|
+
return quarterIndex(left) - quarterIndex(right);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sortQuarterPeriods<T extends QuarterPeriod | string>(periods: readonly T[]): T[] {
|
|
54
|
+
return [...periods].sort(compareQuarterPeriods);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function quarterIndex(period: QuarterPeriod): number {
|
|
58
|
+
assertQuarterPeriod(period);
|
|
59
|
+
return period.year * 4 + period.quarter - 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addQuarters(period: QuarterPeriod | string, count: number): QuarterPeriod {
|
|
63
|
+
if (!Number.isSafeInteger(count)) {
|
|
64
|
+
throw new ReservingError("BAD_ORIGIN", `Quarter offset must be an integer; got ${count}`);
|
|
65
|
+
}
|
|
66
|
+
const parsed = typeof period === "string" ? parseQuarterPeriod(period) : assertQuarterPeriod(period);
|
|
67
|
+
const index = quarterIndex(parsed) + count;
|
|
68
|
+
const year = Math.floor(index / 4);
|
|
69
|
+
const quarter = ((index % 4 + 4) % 4 + 1) as QuarterNumber;
|
|
70
|
+
return assertQuarterPeriod({ year, quarter });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Development age in months. The default reflects quarter-end snapshots:
|
|
75
|
+
* the first observation in an origin quarter is age 3. Select `elapsed` only
|
|
76
|
+
* when the source genuinely defines a same-quarter observation as age zero.
|
|
77
|
+
*/
|
|
78
|
+
export function developmentAgeMonths(
|
|
79
|
+
origin: QuarterPeriod | string,
|
|
80
|
+
valuation: QuarterPeriod | string,
|
|
81
|
+
convention: DevelopmentAgeConvention = "quarter-end-first-observation",
|
|
82
|
+
): number {
|
|
83
|
+
const o = typeof origin === "string" ? parseQuarterPeriod(origin) : assertQuarterPeriod(origin);
|
|
84
|
+
const v = typeof valuation === "string" ? parseQuarterPeriod(valuation) : assertQuarterPeriod(valuation);
|
|
85
|
+
const elapsed = quarterIndex(v) - quarterIndex(o);
|
|
86
|
+
if (elapsed < 0) {
|
|
87
|
+
throw new ReservingError(
|
|
88
|
+
"BAD_DATE",
|
|
89
|
+
`Valuation quarter ${formatQuarterPeriod(v)} precedes origin quarter ${formatQuarterPeriod(o)}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return (elapsed + (convention === "quarter-end-first-observation" ? 1 : 0)) * 3;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PolicyPeriodOptions {
|
|
96
|
+
/** First calendar quarter in the policy/fiscal year. Defaults to Q1. */
|
|
97
|
+
startQuarter?: QuarterNumber;
|
|
98
|
+
/** Caller override for nonstandard labels or boundaries. */
|
|
99
|
+
mapper?: (period: QuarterPeriod) => string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Maps a calendar quarter to the starting year of its policy/fiscal period. */
|
|
103
|
+
export function policyPeriodLabel(
|
|
104
|
+
period: QuarterPeriod | string,
|
|
105
|
+
options: PolicyPeriodOptions = {},
|
|
106
|
+
): string {
|
|
107
|
+
const parsed = typeof period === "string" ? parseQuarterPeriod(period) : assertQuarterPeriod(period);
|
|
108
|
+
if (options.mapper) return options.mapper({ ...parsed });
|
|
109
|
+
const startQuarter = options.startQuarter ?? 1;
|
|
110
|
+
if (![1, 2, 3, 4].includes(startQuarter)) {
|
|
111
|
+
throw new ReservingError("BAD_ORIGIN", `Policy-year startQuarter must be 1 through 4; got ${startQuarter}`);
|
|
112
|
+
}
|
|
113
|
+
const startYear = startQuarter === 1 || parsed.quarter >= startQuarter ? parsed.year : parsed.year - 1;
|
|
114
|
+
return String(startYear);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CompleteQuarterCutoffOptions {
|
|
118
|
+
/** Include the in-progress quarter. Default false: only completed quarters. */
|
|
119
|
+
includePartial?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Returns the latest included quarter for an ISO calendar date. */
|
|
123
|
+
export function completeQuarterCutoff(
|
|
124
|
+
asOfDate: string,
|
|
125
|
+
options: CompleteQuarterCutoffOptions = {},
|
|
126
|
+
): QuarterPeriod {
|
|
127
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(asOfDate);
|
|
128
|
+
if (!match) throw new ReservingError("BAD_DATE", `asOfDate must be YYYY-MM-DD; got ${JSON.stringify(asOfDate)}`);
|
|
129
|
+
const year = Number(match[1]);
|
|
130
|
+
const month = Number(match[2]);
|
|
131
|
+
const day = Number(match[3]);
|
|
132
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
133
|
+
const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
134
|
+
if (year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > daysInMonth[month - 1]!) {
|
|
135
|
+
throw new ReservingError("BAD_DATE", `asOfDate is not a valid calendar date: ${asOfDate}`);
|
|
136
|
+
}
|
|
137
|
+
const quarter = (Math.floor((month - 1) / 3) + 1) as QuarterNumber;
|
|
138
|
+
if (options.includePartial) return { year, quarter };
|
|
139
|
+
const endMonth = quarter * 3;
|
|
140
|
+
const endDay = daysInMonth[endMonth - 1]!;
|
|
141
|
+
if (month === endMonth && day === endDay) return { year, quarter };
|
|
142
|
+
return addQuarters({ year, quarter }, -1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface CompleteQuarterlyCutoffsOptions {
|
|
146
|
+
originAsOfDate?: string;
|
|
147
|
+
valuationAsOfDate?: string;
|
|
148
|
+
includePartialOrigin?: boolean;
|
|
149
|
+
includePartialValuation?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Explicit origin and valuation cutoffs, independently configurable. */
|
|
153
|
+
export function completeQuarterlyCutoffs(
|
|
154
|
+
asOfDate: string,
|
|
155
|
+
options: CompleteQuarterlyCutoffsOptions = {},
|
|
156
|
+
): { originThrough: QuarterPeriod; valuationThrough: QuarterPeriod } {
|
|
157
|
+
return {
|
|
158
|
+
originThrough: completeQuarterCutoff(options.originAsOfDate ?? asOfDate, {
|
|
159
|
+
includePartial: options.includePartialOrigin,
|
|
160
|
+
}),
|
|
161
|
+
valuationThrough: completeQuarterCutoff(options.valuationAsOfDate ?? asOfDate, {
|
|
162
|
+
includePartial: options.includePartialValuation,
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function assertQuarterPeriod(period: QuarterPeriod): QuarterPeriod {
|
|
168
|
+
if (
|
|
169
|
+
!Number.isSafeInteger(period.year) ||
|
|
170
|
+
period.year < 1 ||
|
|
171
|
+
period.year > 9999 ||
|
|
172
|
+
![1, 2, 3, 4].includes(period.quarter)
|
|
173
|
+
) {
|
|
174
|
+
throw new ReservingError("BAD_ORIGIN", `Invalid quarter period ${JSON.stringify(period)}`);
|
|
175
|
+
}
|
|
176
|
+
return period;
|
|
177
|
+
}
|