@actuarial-ts/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/canonical.d.ts +19 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +118 -0
- package/dist/canonical.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mack.d.ts.map +1 -1
- package/dist/mack.js +58 -18
- package/dist/mack.js.map +1 -1
- package/dist/odpBootstrap.d.ts +8 -2
- package/dist/odpBootstrap.d.ts.map +1 -1
- package/dist/odpBootstrap.js +11 -2
- package/dist/odpBootstrap.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/package.json +4 -2
- package/src/benktander.ts +90 -0
- package/src/berquist.ts +349 -0
- package/src/bf.ts +129 -0
- package/src/canonical.ts +122 -0
- package/src/capping.ts +295 -0
- package/src/caseOutstanding.ts +270 -0
- package/src/chainladder.ts +101 -0
- package/src/clark.ts +719 -0
- package/src/diagnostics.ts +435 -0
- package/src/discounting.ts +417 -0
- package/src/elrMethods.ts +257 -0
- package/src/factors.ts +147 -0
- package/src/fisherLange.ts +374 -0
- package/src/freqSev.ts +152 -0
- package/src/ilf.ts +567 -0
- package/src/index.ts +29 -0
- package/src/mack.ts +329 -0
- package/src/merzWuthrich.ts +147 -0
- package/src/munichChainLadder.ts +398 -0
- package/src/odpBootstrap.ts +337 -0
- package/src/onlevel.ts +155 -0
- package/src/salvageSubro.ts +205 -0
- package/src/stochastic.ts +151 -0
- package/src/tail.ts +156 -0
- package/src/trend.ts +150 -0
- package/src/triangle.ts +235 -0
- package/src/triangleAlgebra.ts +111 -0
- package/src/types.ts +357 -0
- package/src/ulae.ts +326 -0
- package/src/util.ts +68 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import type { ChainLadderResult } from "./types.js";
|
|
2
|
+
import { ReservingError } from "./types.js";
|
|
3
|
+
import { isNum, safeRatio } from "./util.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Discounting unpaid claim estimates per ASOP No. 20 ("Discounting of
|
|
7
|
+
* Property/Casualty Unpaid Claim Estimates", the revised standard effective
|
|
8
|
+
* June 1, 2026). The standard's discipline, encoded rather than footnoted:
|
|
9
|
+
*
|
|
10
|
+
* - Rate PROVENANCE is required input ({ source, asOfDate }): the standard
|
|
11
|
+
* requires the actuary to disclose the basis of the discount rates and the
|
|
12
|
+
* date as of which they were determined. This module refuses to discount
|
|
13
|
+
* without it.
|
|
14
|
+
* - Nominal and discounted amounts are reported SIDE BY SIDE, per origin and
|
|
15
|
+
* in total, with the effective discount factor between them. Neither
|
|
16
|
+
* replaces the other.
|
|
17
|
+
* - Risk margins are EXPLICIT ONLY: an optional `riskMargin` amount passes
|
|
18
|
+
* through the result untouched; no total in this module adds it in.
|
|
19
|
+
* Implicit margins (conservative rates, shaded patterns) are never
|
|
20
|
+
* manufactured here, and blending a margin into the discounted figure is
|
|
21
|
+
* left to the consumer to do visibly.
|
|
22
|
+
*
|
|
23
|
+
* Timing convention (payments assumed at period boundaries per the pattern):
|
|
24
|
+
* each expected payment occupies a development interval (fromMonths,
|
|
25
|
+
* toMonths] measured from the VALUATION DATE, i.e. the latest observed
|
|
26
|
+
* diagonal of the triangle behind the pattern. The `convention` option fixes
|
|
27
|
+
* the discount exponent for the interval's payment:
|
|
28
|
+
*
|
|
29
|
+
* - "end-period": t = toMonths / 12 years - the payment falls on the
|
|
30
|
+
* interval's closing boundary.
|
|
31
|
+
* - "mid-period": t = (fromMonths + toMonths) / 24 years - the standard
|
|
32
|
+
* uniform-payment approximation (cash spread evenly over the interval is
|
|
33
|
+
* discounted as if paid at its midpoint).
|
|
34
|
+
*
|
|
35
|
+
* Discount factors are (1 + rate)^(-t) with annual effective spot rates.
|
|
36
|
+
* Under a spot curve, a payment at time t uses the rate for the year
|
|
37
|
+
* containing t (t in (k-1, k] years uses spotByYear[k-1]); payments beyond
|
|
38
|
+
* the curve horizon use the LAST spot rate, with a warning.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export type DiscountConvention = "mid-period" | "end-period";
|
|
42
|
+
|
|
43
|
+
/** One expected payment, located relative to the valuation date. */
|
|
44
|
+
export interface PayoutCashflow {
|
|
45
|
+
/** Months after the valuation date at which the payment interval opens. */
|
|
46
|
+
fromMonths: number;
|
|
47
|
+
/** Months after the valuation date at which the interval closes. */
|
|
48
|
+
toMonths: number;
|
|
49
|
+
/** Expected payment within the interval (negative = expected recovery). */
|
|
50
|
+
amount: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PayoutPatternRow {
|
|
54
|
+
origin: string;
|
|
55
|
+
/** Age (months) of the latest observed diagonal cell (the valuation age). */
|
|
56
|
+
latestAge: number;
|
|
57
|
+
/** The chain ladder unpaid this row's cashflows tie to (sum of amounts). */
|
|
58
|
+
unpaid: number;
|
|
59
|
+
cashflows: PayoutCashflow[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PayoutPattern {
|
|
63
|
+
rows: PayoutPatternRow[];
|
|
64
|
+
warnings: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Expected future incremental payments per origin, derived from a chain
|
|
69
|
+
* ladder result: the increment for development interval (ages[j], ages[j+1]]
|
|
70
|
+
* is ultimate x (percentDeveloped[j+1] - percentDeveloped[j]), and the tail
|
|
71
|
+
* cash implied by a tail factor > 1 (ultimate x (1 - percentDeveloped at the
|
|
72
|
+
* last age)) is compressed into a single interval of the same width as the
|
|
73
|
+
* last observed development step, immediately after the last age (warned; a
|
|
74
|
+
* single-age triangle uses a 12-month tail interval). Each row's cashflow
|
|
75
|
+
* amounts sum to its unpaid by construction.
|
|
76
|
+
*
|
|
77
|
+
* `ages` must be the development ages the chain ladder ran on (the result
|
|
78
|
+
* does not carry them). Negative increments (percent developed not monotone,
|
|
79
|
+
* i.e. an LDF below 1) are kept and warned - never dropped or fabricated
|
|
80
|
+
* away.
|
|
81
|
+
*/
|
|
82
|
+
export function payoutPatternFromChainLadder(
|
|
83
|
+
cl: ChainLadderResult,
|
|
84
|
+
ages: number[],
|
|
85
|
+
): PayoutPattern {
|
|
86
|
+
const K = ages.length;
|
|
87
|
+
if (K === 0 || cl.cdfs.length !== K) {
|
|
88
|
+
throw new ReservingError(
|
|
89
|
+
"SHAPE",
|
|
90
|
+
`Expected the development ages the chain ladder ran on (${cl.cdfs.length} CDFs), got ${K} ages`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
for (let j = 0; j < K; j++) {
|
|
94
|
+
const a = ages[j]!;
|
|
95
|
+
if (!isNum(a) || a <= 0 || (j > 0 && a <= ages[j - 1]!)) {
|
|
96
|
+
throw new ReservingError("SHAPE", "Development ages must be finite, positive, and ascending");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const warnings: string[] = [];
|
|
101
|
+
const pct = cl.percentDeveloped;
|
|
102
|
+
const lastStep = K >= 2 ? ages[K - 1]! - ages[K - 2]! : 12;
|
|
103
|
+
const negativeColumns = new Set<number>();
|
|
104
|
+
let tailWarned = false;
|
|
105
|
+
|
|
106
|
+
const rows: PayoutPatternRow[] = [];
|
|
107
|
+
for (const row of cl.rows) {
|
|
108
|
+
const k = ages.indexOf(row.latestAge);
|
|
109
|
+
if (k < 0) {
|
|
110
|
+
throw new ReservingError(
|
|
111
|
+
"SHAPE",
|
|
112
|
+
`Origin ${row.origin}: latest age ${row.latestAge} is not among the supplied ages; pass the same ages the chain ladder ran on`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const cashflows: PayoutCashflow[] = [];
|
|
116
|
+
for (let j = k; j < K - 1; j++) {
|
|
117
|
+
const amount = row.ultimate * (pct[j + 1]! - pct[j]!);
|
|
118
|
+
if (amount < 0 && !negativeColumns.has(j)) {
|
|
119
|
+
negativeColumns.add(j);
|
|
120
|
+
warnings.push(
|
|
121
|
+
`Development interval ${ages[j]}-${ages[j + 1]} months has a negative expected payment (percent developed is not monotone there); negative cashflows are kept, never dropped`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
cashflows.push({
|
|
125
|
+
fromMonths: ages[j]! - ages[k]!,
|
|
126
|
+
toMonths: ages[j + 1]! - ages[k]!,
|
|
127
|
+
amount,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const tailAmount = row.ultimate * (1 - pct[K - 1]!);
|
|
131
|
+
if (Math.abs(tailAmount) > 1e-9 * Math.max(1, Math.abs(row.ultimate))) {
|
|
132
|
+
if (!tailWarned) {
|
|
133
|
+
tailWarned = true;
|
|
134
|
+
warnings.push(
|
|
135
|
+
`Tail development beyond ${ages[K - 1]} months is compressed into a single ${lastStep}-month payment interval immediately after the last age; supply explicit cashflows if the tail is long`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
cashflows.push({
|
|
139
|
+
fromMonths: ages[K - 1]! - ages[k]!,
|
|
140
|
+
toMonths: ages[K - 1]! - ages[k]! + lastStep,
|
|
141
|
+
amount: tailAmount,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
rows.push({ origin: row.origin, latestAge: row.latestAge, unpaid: row.unpaid, cashflows });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Off-diagonal guard: cashflow timing measures every row from its OWN
|
|
148
|
+
// latest observed age, which is only calendar-correct when all rows sit on
|
|
149
|
+
// one valuation diagonal. A stale origin (data feed stopped early) has a
|
|
150
|
+
// latest age at or below a YOUNGER origin's — its calendar-past
|
|
151
|
+
// development would be discounted as future. Rows clamped at the final age
|
|
152
|
+
// column are legitimate (fully-developed old origins on truncated-wide
|
|
153
|
+
// grids), so equality at the last age never flags. Warn, never re-time.
|
|
154
|
+
const maxAge = ages[K - 1]!;
|
|
155
|
+
for (let i = 0; i < rows.length; i++) {
|
|
156
|
+
const r = rows[i]!;
|
|
157
|
+
if (r.latestAge >= maxAge) continue;
|
|
158
|
+
if (!r.cashflows.some((c) => Math.abs(c.amount) > 1e-9)) continue;
|
|
159
|
+
const shadowedBy = rows.slice(i + 1).find((later) => later.latestAge >= r.latestAge);
|
|
160
|
+
if (shadowedBy) {
|
|
161
|
+
warnings.push(
|
|
162
|
+
`Origin ${r.origin} appears stale (latest age ${r.latestAge} months, at or below younger origin ${shadowedBy.origin}'s ${shadowedBy.latestAge}); its cashflow timing assumes its latest cell sits AT the valuation date, so calendar-past development may be discounted as future`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { rows, warnings };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Discount rates, annual effective:
|
|
171
|
+
* - "flat": one rate for every maturity.
|
|
172
|
+
* - "curve": spotByYear[k] is the spot rate for payments in year k+1 (times
|
|
173
|
+
* t in (k, k+1] years). Payments beyond the horizon use the last rate.
|
|
174
|
+
*/
|
|
175
|
+
export type DiscountRates =
|
|
176
|
+
| { kind: "flat"; annualRate: number }
|
|
177
|
+
| { kind: "curve"; spotByYear: number[] };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Where the rates came from and when - REQUIRED, because ASOP 20 requires
|
|
181
|
+
* the actuary to disclose the basis of the discount rate(s) and the date as
|
|
182
|
+
* of which they were determined.
|
|
183
|
+
*/
|
|
184
|
+
export interface RateProvenance {
|
|
185
|
+
/** e.g. "US Treasury CMT curve", "company investment yield per ASOP 20 5.3". */
|
|
186
|
+
source: string;
|
|
187
|
+
/** ISO date (yyyy-mm-dd) the rates were determined as of. */
|
|
188
|
+
asOfDate: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Explicit cashflows for one origin (the non-pattern input path). */
|
|
192
|
+
export interface OriginCashflows {
|
|
193
|
+
origin: string;
|
|
194
|
+
cashflows: PayoutCashflow[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface DiscountUnpaidInput {
|
|
198
|
+
/** Payout pattern from payoutPatternFromChainLadder. Exactly one of pattern/cashflows. */
|
|
199
|
+
pattern?: PayoutPattern;
|
|
200
|
+
/** Explicit per-origin cashflows. Exactly one of pattern/cashflows. */
|
|
201
|
+
cashflows?: OriginCashflows[];
|
|
202
|
+
rates: DiscountRates;
|
|
203
|
+
provenance: RateProvenance;
|
|
204
|
+
convention: DiscountConvention;
|
|
205
|
+
/**
|
|
206
|
+
* Explicit risk margin (a dollar amount, not a rate). Carried through the
|
|
207
|
+
* result UNCHANGED and kept out of every total - never blended.
|
|
208
|
+
*/
|
|
209
|
+
riskMargin?: number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface DiscountedCashflowDetail {
|
|
213
|
+
/** Payment time in years from the valuation date under the chosen convention. */
|
|
214
|
+
timeYears: number;
|
|
215
|
+
amount: number;
|
|
216
|
+
/** Annual effective spot rate applied at timeYears. */
|
|
217
|
+
rate: number;
|
|
218
|
+
/** (1 + rate)^(-timeYears). */
|
|
219
|
+
discountFactor: number;
|
|
220
|
+
discounted: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface DiscountedUnpaidRow {
|
|
224
|
+
origin: string;
|
|
225
|
+
/** Undiscounted expected future payments (sum of cashflow amounts). */
|
|
226
|
+
nominal: number;
|
|
227
|
+
discounted: number;
|
|
228
|
+
/** nominal - discounted (the amount of discount taken). */
|
|
229
|
+
discount: number;
|
|
230
|
+
/** discounted / nominal; null when nominal is not positive. */
|
|
231
|
+
effectiveDiscountFactor: number | null;
|
|
232
|
+
cashflows: DiscountedCashflowDetail[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface DiscountUnpaidResult {
|
|
236
|
+
method: "discountUnpaid";
|
|
237
|
+
convention: DiscountConvention;
|
|
238
|
+
rates: DiscountRates;
|
|
239
|
+
provenance: RateProvenance;
|
|
240
|
+
rows: DiscountedUnpaidRow[];
|
|
241
|
+
totals: {
|
|
242
|
+
nominal: number;
|
|
243
|
+
discounted: number;
|
|
244
|
+
discount: number;
|
|
245
|
+
effectiveDiscountFactor: number | null;
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* The caller's explicit risk margin, passed through untouched (null when
|
|
249
|
+
* none was supplied). NOT included in any total above: presenting it as a
|
|
250
|
+
* separate line is the consumer's job, per the explicit-only rule.
|
|
251
|
+
*/
|
|
252
|
+
riskMargin: number | null;
|
|
253
|
+
warnings: string[];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const ISO_DATE = /^\d{4}-(\d{2})-(\d{2})$/;
|
|
257
|
+
|
|
258
|
+
function validateProvenance(provenance: RateProvenance | undefined): RateProvenance {
|
|
259
|
+
if (
|
|
260
|
+
provenance === undefined ||
|
|
261
|
+
typeof provenance.source !== "string" ||
|
|
262
|
+
provenance.source.trim() === ""
|
|
263
|
+
) {
|
|
264
|
+
throw new ReservingError(
|
|
265
|
+
"NO_PROVENANCE",
|
|
266
|
+
"Rate provenance is required: ASOP 20 requires disclosing the basis of the discount rates (source) and the date they were determined (asOfDate)",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const match =
|
|
270
|
+
typeof provenance.asOfDate === "string" ? ISO_DATE.exec(provenance.asOfDate) : null;
|
|
271
|
+
const m = match ? Number(match[1]) : 0;
|
|
272
|
+
const d = match ? Number(match[2]) : 0;
|
|
273
|
+
if (!match || m < 1 || m > 12 || d < 1 || d > 31) {
|
|
274
|
+
throw new ReservingError(
|
|
275
|
+
"BAD_DATE",
|
|
276
|
+
`Rate provenance asOfDate must be an ISO date (yyyy-mm-dd), got "${provenance.asOfDate}"`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return provenance;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function validateRates(rates: DiscountRates): void {
|
|
283
|
+
if (rates.kind === "flat") {
|
|
284
|
+
if (!isNum(rates.annualRate) || rates.annualRate <= -1) {
|
|
285
|
+
throw new ReservingError(
|
|
286
|
+
"BAD_RATE",
|
|
287
|
+
`Flat annual discount rate must be a finite number greater than -100%, got ${rates.annualRate}`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (rates.spotByYear.length === 0) {
|
|
293
|
+
throw new ReservingError("BAD_RATE", "A spot curve needs at least one annual rate");
|
|
294
|
+
}
|
|
295
|
+
rates.spotByYear.forEach((r, k) => {
|
|
296
|
+
if (!isNum(r) || r <= -1) {
|
|
297
|
+
throw new ReservingError(
|
|
298
|
+
"BAD_RATE",
|
|
299
|
+
`Spot rate for year ${k + 1} must be a finite number greater than -100%, got ${r}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Discounts expected future payments and reports nominal and discounted
|
|
307
|
+
* unpaid side by side, per origin and in total. See the module doc for the
|
|
308
|
+
* timing convention, curve lookup, and the ASOP 20 provenance/risk-margin
|
|
309
|
+
* rules this function enforces.
|
|
310
|
+
*/
|
|
311
|
+
export function discountUnpaid(input: DiscountUnpaidInput): DiscountUnpaidResult {
|
|
312
|
+
const hasPattern = input.pattern !== undefined;
|
|
313
|
+
const hasCashflows = input.cashflows !== undefined;
|
|
314
|
+
if (hasPattern === hasCashflows) {
|
|
315
|
+
throw new ReservingError(
|
|
316
|
+
"BAD_CASHFLOWS",
|
|
317
|
+
"Provide exactly one cashflow source: a payout pattern OR explicit per-origin cashflows",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
validateRates(input.rates);
|
|
321
|
+
const provenance = validateProvenance(input.provenance);
|
|
322
|
+
if (input.riskMargin !== undefined && (!isNum(input.riskMargin) || input.riskMargin < 0)) {
|
|
323
|
+
throw new ReservingError(
|
|
324
|
+
"BAD_MARGIN",
|
|
325
|
+
`An explicit risk margin must be a finite, non-negative amount, got ${input.riskMargin}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const sources: OriginCashflows[] = hasPattern
|
|
330
|
+
? input.pattern!.rows.map((r) => ({ origin: r.origin, cashflows: r.cashflows }))
|
|
331
|
+
: input.cashflows!;
|
|
332
|
+
if (sources.length === 0) {
|
|
333
|
+
throw new ReservingError("NO_DATA", "No origins to discount");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const warnings: string[] = [];
|
|
337
|
+
const spot = input.rates.kind === "curve" ? input.rates.spotByYear : null;
|
|
338
|
+
let horizonWarned = false;
|
|
339
|
+
const negativeOrigins: string[] = [];
|
|
340
|
+
|
|
341
|
+
const rows: DiscountedUnpaidRow[] = sources.map((source) => {
|
|
342
|
+
let nominal = 0;
|
|
343
|
+
let discounted = 0;
|
|
344
|
+
let hasNegative = false;
|
|
345
|
+
const details: DiscountedCashflowDetail[] = source.cashflows.map((cf) => {
|
|
346
|
+
if (
|
|
347
|
+
!isNum(cf.fromMonths) ||
|
|
348
|
+
cf.fromMonths < 0 ||
|
|
349
|
+
!isNum(cf.toMonths) ||
|
|
350
|
+
cf.toMonths < cf.fromMonths ||
|
|
351
|
+
!isNum(cf.amount)
|
|
352
|
+
) {
|
|
353
|
+
throw new ReservingError(
|
|
354
|
+
"BAD_CASHFLOWS",
|
|
355
|
+
`Origin ${source.origin}: cashflows need finite amounts and 0 <= fromMonths <= toMonths`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
if (cf.amount < 0) hasNegative = true;
|
|
359
|
+
const timeYears =
|
|
360
|
+
input.convention === "end-period"
|
|
361
|
+
? cf.toMonths / 12
|
|
362
|
+
: (cf.fromMonths + cf.toMonths) / 24;
|
|
363
|
+
let rate: number;
|
|
364
|
+
if (spot === null) {
|
|
365
|
+
rate = (input.rates as { kind: "flat"; annualRate: number }).annualRate;
|
|
366
|
+
} else {
|
|
367
|
+
if (timeYears > spot.length && !horizonWarned) {
|
|
368
|
+
horizonWarned = true;
|
|
369
|
+
warnings.push(
|
|
370
|
+
`Cashflows beyond the ${spot.length}-year spot curve horizon are discounted at the last spot rate (${spot[spot.length - 1]}); extend the curve to refine`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
rate = spot[Math.min(Math.max(Math.ceil(timeYears), 1), spot.length) - 1]!;
|
|
374
|
+
}
|
|
375
|
+
const discountFactor = (1 + rate) ** -timeYears;
|
|
376
|
+
const pv = cf.amount * discountFactor;
|
|
377
|
+
nominal += cf.amount;
|
|
378
|
+
discounted += pv;
|
|
379
|
+
return { timeYears, amount: cf.amount, rate, discountFactor, discounted: pv };
|
|
380
|
+
});
|
|
381
|
+
if (hasNegative) negativeOrigins.push(source.origin);
|
|
382
|
+
return {
|
|
383
|
+
origin: source.origin,
|
|
384
|
+
nominal,
|
|
385
|
+
discounted,
|
|
386
|
+
discount: nominal - discounted,
|
|
387
|
+
effectiveDiscountFactor: safeRatio(discounted, nominal),
|
|
388
|
+
cashflows: details,
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
if (negativeOrigins.length > 0) {
|
|
393
|
+
warnings.push(
|
|
394
|
+
`Negative expected cashflows for origin(s) ${negativeOrigins.join(", ")}; discounted as-is (negative flows reduce the discounted unpaid)`,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const totals = rows.reduce(
|
|
399
|
+
(acc, r) => ({
|
|
400
|
+
nominal: acc.nominal + r.nominal,
|
|
401
|
+
discounted: acc.discounted + r.discounted,
|
|
402
|
+
discount: acc.discount + r.discount,
|
|
403
|
+
}),
|
|
404
|
+
{ nominal: 0, discounted: 0, discount: 0 },
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
method: "discountUnpaid",
|
|
409
|
+
convention: input.convention,
|
|
410
|
+
rates: input.rates,
|
|
411
|
+
provenance,
|
|
412
|
+
rows,
|
|
413
|
+
totals: { ...totals, effectiveDiscountFactor: safeRatio(totals.discounted, totals.nominal) },
|
|
414
|
+
riskMargin: input.riskMargin ?? null,
|
|
415
|
+
warnings,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { ReservingError } from "./types.js";
|
|
2
|
+
import { isNum } from "./util.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expected-loss-ratio methods (Friedland ch. 8 and 10).
|
|
6
|
+
*
|
|
7
|
+
* Cape Cod (Stanard-Buhlmann) derives its ELR mechanically from the data:
|
|
8
|
+
* ELR* = sum(adjusted reported) / sum(adjusted used-up premium), where the
|
|
9
|
+
* per-origin adjustment factors bring losses and premium to a common (target)
|
|
10
|
+
* cost and rate level. Expected unreported for each origin then comes from
|
|
11
|
+
* the ELR restated to THAT origin's own level.
|
|
12
|
+
*
|
|
13
|
+
* The Expected Claims method applies a selected ELR (at target level) the
|
|
14
|
+
* same way, with no reliance on the origin's own emerged losses.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ElrMethodRow {
|
|
18
|
+
origin: string;
|
|
19
|
+
/** Reported (or paid) losses on the latest diagonal, the method's basis. */
|
|
20
|
+
reported: number;
|
|
21
|
+
/** Cumulative development factor to ultimate for this origin's age. */
|
|
22
|
+
cdf: number;
|
|
23
|
+
/** Earned premium for the origin (raw). */
|
|
24
|
+
premium: number;
|
|
25
|
+
/**
|
|
26
|
+
* Factor bringing this origin's LOSSES to the target cost level
|
|
27
|
+
* (e.g. (1+sevTrend)^(target-year) x uncap adjustment). Default 1.
|
|
28
|
+
*/
|
|
29
|
+
lossAdj?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Factor bringing this origin's PREMIUM to the target rate/cost level
|
|
32
|
+
* (on-level factor x premium trend). Default 1.
|
|
33
|
+
*/
|
|
34
|
+
premiumAdj?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CapeCodRow {
|
|
38
|
+
origin: string;
|
|
39
|
+
reported: number;
|
|
40
|
+
cdf: number;
|
|
41
|
+
premium: number;
|
|
42
|
+
usedUpPremium: number;
|
|
43
|
+
/**
|
|
44
|
+
* The ELR governing THIS origin at the target level. With decay = 1 it is
|
|
45
|
+
* the single pooled Cape Cod ELR (same for every row); with decay < 1 it
|
|
46
|
+
* is the origin's own decay-weighted average (Gluck 1997).
|
|
47
|
+
*/
|
|
48
|
+
elrAtTargetLevel: number;
|
|
49
|
+
/** elrAtTargetLevel restated to this origin's own level. */
|
|
50
|
+
elrAtOriginLevel: number;
|
|
51
|
+
expectedUltimate: number;
|
|
52
|
+
ultimate: number;
|
|
53
|
+
ibnrToReported: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface CapeCodResult {
|
|
57
|
+
method: "capeCod";
|
|
58
|
+
/** The mechanical ELR at the TARGET (adjusted) level. */
|
|
59
|
+
elrAtTargetLevel: number;
|
|
60
|
+
rows: CapeCodRow[];
|
|
61
|
+
totals: { reported: number; ultimate: number; usedUpPremium: number };
|
|
62
|
+
warnings: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateRows(rows: ElrMethodRow[]): void {
|
|
66
|
+
if (rows.length === 0) {
|
|
67
|
+
throw new ReservingError("NO_DATA", "ELR methods need at least one origin row");
|
|
68
|
+
}
|
|
69
|
+
for (const r of rows) {
|
|
70
|
+
// CDFs below 1 are legitimate on incurred bases (case run-off develops
|
|
71
|
+
// reported incurred DOWNWARD); the methods then post an expected
|
|
72
|
+
// take-down rather than a provision. Only non-positive CDFs are garbage.
|
|
73
|
+
if (!isNum(r.cdf) || r.cdf <= 0) {
|
|
74
|
+
throw new ReservingError(
|
|
75
|
+
"BAD_CDF",
|
|
76
|
+
`Origin ${r.origin}: the CDF to ultimate must be positive (got ${r.cdf})`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!isNum(r.premium) || r.premium <= 0) {
|
|
80
|
+
throw new ReservingError(
|
|
81
|
+
"BAD_PREMIUM",
|
|
82
|
+
`Origin ${r.origin}: the exposure base (earned premium or exposure units) must be positive`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (!isNum(r.reported) || r.reported < 0) {
|
|
86
|
+
throw new ReservingError("BAD_LOSSES", `Origin ${r.origin}: reported must be >= 0`);
|
|
87
|
+
}
|
|
88
|
+
const lossAdj = r.lossAdj ?? 1;
|
|
89
|
+
const premiumAdj = r.premiumAdj ?? 1;
|
|
90
|
+
if (!isNum(lossAdj) || lossAdj <= 0 || !isNum(premiumAdj) || premiumAdj <= 0) {
|
|
91
|
+
throw new ReservingError(
|
|
92
|
+
"BAD_ADJ",
|
|
93
|
+
`Origin ${r.origin}: adjustment factors must be positive`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Cape Cod: ELR* = sum(reported x lossAdj) / sum(premium x premiumAdj / cdf).
|
|
101
|
+
* Ultimate_i = reported_i + expectedUltimate_i x (1 - 1/cdf_i), with
|
|
102
|
+
* expectedUltimate_i = ELR* x premium_i x premiumAdj_i / lossAdj_i (the
|
|
103
|
+
* target-level ELR restated to origin i's own level, times its premium).
|
|
104
|
+
*
|
|
105
|
+
* Generalized Cape Cod (Gluck 1997, PCAS LXXXIV, eq. 6.1): with a decay
|
|
106
|
+
* factor D in [0, 1], each origin gets its OWN target-level ELR — the
|
|
107
|
+
* weighted average over all origins with weights usedUp_j x D^|i-j|
|
|
108
|
+
* (distance in origin-row steps). D = 1 is the standard Cape Cod (single
|
|
109
|
+
* pooled ELR); D = 0 makes every year stand alone, reproducing the pure
|
|
110
|
+
* development ultimate. Gluck's practical guidance: D between 0.50 and
|
|
111
|
+
* 1.00, with 0.75 as a customary default; lower D suits large stable books
|
|
112
|
+
* (trend error dominates), higher D suits small erratic ones (development
|
|
113
|
+
* error dominates).
|
|
114
|
+
*/
|
|
115
|
+
export function runCapeCod(
|
|
116
|
+
rows: ElrMethodRow[],
|
|
117
|
+
opts: { baseIsPurePremium?: boolean; decay?: number } = {},
|
|
118
|
+
): CapeCodResult {
|
|
119
|
+
validateRows(rows);
|
|
120
|
+
const warnings: string[] = [];
|
|
121
|
+
const decay = opts.decay ?? 1;
|
|
122
|
+
if (!isNum(decay) || decay < 0 || decay > 1) {
|
|
123
|
+
throw new ReservingError("BAD_ADJ", `The decay factor must be between 0 and 1 (got ${decay})`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const losses = rows.map((r) => r.reported * (r.lossAdj ?? 1));
|
|
127
|
+
const usedUps = rows.map((r) => (r.premium * (r.premiumAdj ?? 1)) / r.cdf);
|
|
128
|
+
let lossSum = 0;
|
|
129
|
+
let usedUpSum = 0;
|
|
130
|
+
for (let j = 0; j < rows.length; j++) {
|
|
131
|
+
lossSum += losses[j]!;
|
|
132
|
+
usedUpSum += usedUps[j]!;
|
|
133
|
+
}
|
|
134
|
+
if (!(usedUpSum > 0)) {
|
|
135
|
+
throw new ReservingError("BAD_PREMIUM", "Used-up premium is not positive");
|
|
136
|
+
}
|
|
137
|
+
const pooledElr = lossSum / usedUpSum;
|
|
138
|
+
|
|
139
|
+
// Per-origin target-level ELR: pooled when D = 1 (same float, same code
|
|
140
|
+
// path — the published Cape Cod behavior must stay byte-identical), the
|
|
141
|
+
// Gluck decay-weighted average otherwise. 0^0 = 1 in JS, so D = 0 cleanly
|
|
142
|
+
// reduces to "own year only".
|
|
143
|
+
const elrByRow: number[] = rows.map((_, i) => {
|
|
144
|
+
if (decay === 1) return pooledElr;
|
|
145
|
+
let num = 0;
|
|
146
|
+
let den = 0;
|
|
147
|
+
for (let j = 0; j < rows.length; j++) {
|
|
148
|
+
const w = decay ** Math.abs(i - j);
|
|
149
|
+
num += losses[j]! * w;
|
|
150
|
+
den += usedUps[j]! * w;
|
|
151
|
+
}
|
|
152
|
+
if (!(den > 0)) {
|
|
153
|
+
throw new ReservingError(
|
|
154
|
+
"BAD_PREMIUM",
|
|
155
|
+
`Origin ${rows[i]!.origin}: decayed used-up premium is not positive`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return num / den;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (rows.some((r) => r.cdf < 1)) {
|
|
162
|
+
warnings.push(
|
|
163
|
+
"Some origins have CDFs below 1 (expected downward development): their Cape Cod provision is an expected take-down, standard for incurred bases with case redundancy",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
// A pure premium (losses per exposure unit) is a dollar amount, not a ratio,
|
|
167
|
+
// so the "ELR looks too high" sanity check only applies to the loss-ratio base.
|
|
168
|
+
if (!opts.baseIsPurePremium && pooledElr > 2) {
|
|
169
|
+
warnings.push(
|
|
170
|
+
`Cape Cod mechanical ELR is ${(pooledElr * 100).toFixed(0)}% - check that premium and losses are on comparable levels`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const out: CapeCodRow[] = rows.map((r, i) => {
|
|
175
|
+
const lossAdj = r.lossAdj ?? 1;
|
|
176
|
+
const premiumAdj = r.premiumAdj ?? 1;
|
|
177
|
+
const usedUp = (r.premium * premiumAdj) / r.cdf;
|
|
178
|
+
const elrAtTargetLevel = elrByRow[i]!;
|
|
179
|
+
const elrAtOriginLevel = (elrAtTargetLevel * premiumAdj) / lossAdj;
|
|
180
|
+
const expectedUltimate = elrAtOriginLevel * r.premium;
|
|
181
|
+
const ultimate = r.reported + expectedUltimate * (1 - 1 / r.cdf);
|
|
182
|
+
return {
|
|
183
|
+
origin: r.origin,
|
|
184
|
+
reported: r.reported,
|
|
185
|
+
cdf: r.cdf,
|
|
186
|
+
premium: r.premium,
|
|
187
|
+
usedUpPremium: usedUp,
|
|
188
|
+
elrAtTargetLevel,
|
|
189
|
+
elrAtOriginLevel,
|
|
190
|
+
expectedUltimate,
|
|
191
|
+
ultimate,
|
|
192
|
+
ibnrToReported: ultimate - r.reported,
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
method: "capeCod",
|
|
198
|
+
// With decay < 1 there is no single ELR; the scalar is the pooled (D=1)
|
|
199
|
+
// reference value. Per-row elrAtTargetLevel drives the ultimates.
|
|
200
|
+
elrAtTargetLevel: pooledElr,
|
|
201
|
+
rows: out,
|
|
202
|
+
totals: {
|
|
203
|
+
reported: out.reduce((a, r) => a + r.reported, 0),
|
|
204
|
+
ultimate: out.reduce((a, r) => a + r.ultimate, 0),
|
|
205
|
+
usedUpPremium: out.reduce((a, r) => a + r.usedUpPremium, 0),
|
|
206
|
+
},
|
|
207
|
+
warnings,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ExpectedClaimsRow {
|
|
212
|
+
origin: string;
|
|
213
|
+
premium: number;
|
|
214
|
+
elrAtOriginLevel: number;
|
|
215
|
+
ultimate: number;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface ExpectedClaimsResult {
|
|
219
|
+
method: "expectedClaims";
|
|
220
|
+
/** The selected ELR at the target level the caller supplied. */
|
|
221
|
+
selectedElrAtTargetLevel: number;
|
|
222
|
+
rows: ExpectedClaimsRow[];
|
|
223
|
+
totals: { ultimate: number };
|
|
224
|
+
warnings: string[];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Expected Claims: ultimate_i = selectedELR (target level) restated to the
|
|
229
|
+
* origin's level (x premiumAdj_i / lossAdj_i) x premium_i. Pure a-priori -
|
|
230
|
+
* the origin's own emerged losses never enter.
|
|
231
|
+
*/
|
|
232
|
+
export function runExpectedClaims(
|
|
233
|
+
rows: ElrMethodRow[],
|
|
234
|
+
selectedElrAtTargetLevel: number,
|
|
235
|
+
): ExpectedClaimsResult {
|
|
236
|
+
validateRows(rows);
|
|
237
|
+
if (!isNum(selectedElrAtTargetLevel) || selectedElrAtTargetLevel <= 0) {
|
|
238
|
+
throw new ReservingError("BAD_ELR", "The selected ELR must be a positive number");
|
|
239
|
+
}
|
|
240
|
+
const out: ExpectedClaimsRow[] = rows.map((r) => {
|
|
241
|
+
const elrAtOriginLevel =
|
|
242
|
+
(selectedElrAtTargetLevel * (r.premiumAdj ?? 1)) / (r.lossAdj ?? 1);
|
|
243
|
+
return {
|
|
244
|
+
origin: r.origin,
|
|
245
|
+
premium: r.premium,
|
|
246
|
+
elrAtOriginLevel,
|
|
247
|
+
ultimate: elrAtOriginLevel * r.premium,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
method: "expectedClaims",
|
|
252
|
+
selectedElrAtTargetLevel,
|
|
253
|
+
rows: out,
|
|
254
|
+
totals: { ultimate: out.reduce((a, r) => a + r.ultimate, 0) },
|
|
255
|
+
warnings: [],
|
|
256
|
+
};
|
|
257
|
+
}
|