@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.
Files changed (51) hide show
  1. package/README.md +11 -4
  2. package/dist/canonical.d.ts +19 -0
  3. package/dist/canonical.d.ts.map +1 -0
  4. package/dist/canonical.js +118 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mack.d.ts.map +1 -1
  11. package/dist/mack.js +58 -18
  12. package/dist/mack.js.map +1 -1
  13. package/dist/odpBootstrap.d.ts +8 -2
  14. package/dist/odpBootstrap.d.ts.map +1 -1
  15. package/dist/odpBootstrap.js +11 -2
  16. package/dist/odpBootstrap.js.map +1 -1
  17. package/dist/types.d.ts +1 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/dist/types.js +4 -0
  20. package/dist/types.js.map +1 -1
  21. package/package.json +4 -2
  22. package/src/benktander.ts +90 -0
  23. package/src/berquist.ts +349 -0
  24. package/src/bf.ts +129 -0
  25. package/src/canonical.ts +122 -0
  26. package/src/capping.ts +295 -0
  27. package/src/caseOutstanding.ts +270 -0
  28. package/src/chainladder.ts +101 -0
  29. package/src/clark.ts +719 -0
  30. package/src/diagnostics.ts +435 -0
  31. package/src/discounting.ts +417 -0
  32. package/src/elrMethods.ts +257 -0
  33. package/src/factors.ts +147 -0
  34. package/src/fisherLange.ts +374 -0
  35. package/src/freqSev.ts +152 -0
  36. package/src/ilf.ts +567 -0
  37. package/src/index.ts +29 -0
  38. package/src/mack.ts +329 -0
  39. package/src/merzWuthrich.ts +147 -0
  40. package/src/munichChainLadder.ts +398 -0
  41. package/src/odpBootstrap.ts +337 -0
  42. package/src/onlevel.ts +155 -0
  43. package/src/salvageSubro.ts +205 -0
  44. package/src/stochastic.ts +151 -0
  45. package/src/tail.ts +156 -0
  46. package/src/trend.ts +150 -0
  47. package/src/triangle.ts +235 -0
  48. package/src/triangleAlgebra.ts +111 -0
  49. package/src/types.ts +357 -0
  50. package/src/ulae.ts +326 -0
  51. 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
+ }