@actuarial-ts/core 0.2.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/src/capping.ts ADDED
@@ -0,0 +1,295 @@
1
+ import type { ClaimSnapshot } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum } from "./util.js";
4
+
5
+ /**
6
+ * Claim-level loss capping: the foundation of developing a reliable layer.
7
+ *
8
+ * Large losses are volatile and distort development patterns; capping every
9
+ * claim at a per-occurrence limit and developing the capped ("limited")
10
+ * triangles gives stable factors. The excess layer is restored later via
11
+ * increased-limits factors, applied to the developed capped ultimates.
12
+ *
13
+ * The cap can be INDEXED: stated at a base-year cost level and moved by an
14
+ * annual rate so the layer stays constant in real terms across origin years
15
+ * (a flat $250k cap in 2016 is a deeper real layer than in 2025). indexRate 0
16
+ * reproduces the flat-cap convention.
17
+ */
18
+
19
+ export interface CapOptions {
20
+ /** Per-occurrence cap, stated at the baseYear cost level. */
21
+ cap: number;
22
+ /** Annual index rate moving the cap across accident years (0 = flat cap). */
23
+ indexRate?: number;
24
+ /**
25
+ * Accident year at whose cost level `cap` is stated. Defaults to the latest
26
+ * accident year present in the data.
27
+ */
28
+ baseYear?: number;
29
+ }
30
+
31
+ function accidentYear(snap: ClaimSnapshot): number {
32
+ return Number(snap.accidentDate.slice(0, 4));
33
+ }
34
+
35
+ /**
36
+ * The default base year for a cap index: the latest accident year with a
37
+ * snapshot evaluated on or before asOfDate. capClaims and
38
+ * claimSizeDiagnostics MUST resolve this identically, or the applied caps
39
+ * diverge from what the exhibits report; services should resolve it once via
40
+ * this helper and pass baseYear explicitly to both.
41
+ */
42
+ export function latestAccidentYear(claims: ClaimSnapshot[], asOfDate?: string): number {
43
+ const asOf = asOfDate ?? "9999-12-31";
44
+ let year = -Infinity;
45
+ for (const snap of claims) {
46
+ if (snap.evaluationDate > asOf) continue;
47
+ year = Math.max(year, accidentYear(snap));
48
+ }
49
+ if (!Number.isFinite(year)) {
50
+ throw new ReservingError(
51
+ "NO_CLAIMS",
52
+ "No claim snapshots on or before the analysis date; cannot resolve a cap base year",
53
+ );
54
+ }
55
+ return year;
56
+ }
57
+
58
+ /** The effective cap for one accident year under the index. */
59
+ export function effectiveCap(options: Required<CapOptions>, year: number): number {
60
+ return options.cap * Math.pow(1 + options.indexRate, year - options.baseYear);
61
+ }
62
+
63
+ function resolveOptions(claims: ClaimSnapshot[], options: CapOptions): Required<CapOptions> {
64
+ if (!isNum(options.cap) || options.cap <= 0) {
65
+ throw new ReservingError("BAD_CAP", "The per-occurrence cap must be a positive number");
66
+ }
67
+ const indexRate = options.indexRate ?? 0;
68
+ if (!isNum(indexRate) || indexRate <= -1) {
69
+ throw new ReservingError("BAD_CAP", "The cap index rate must be a number greater than -100%");
70
+ }
71
+ let baseYear = options.baseYear ?? null;
72
+ if (baseYear === null) {
73
+ baseYear = -Infinity;
74
+ for (const snap of claims) baseYear = Math.max(baseYear, accidentYear(snap));
75
+ }
76
+ if (!isNum(baseYear) || !Number.isFinite(baseYear)) {
77
+ throw new ReservingError("BAD_CAP", "Could not resolve a base year for the cap index");
78
+ }
79
+ return { cap: options.cap, indexRate, baseYear };
80
+ }
81
+
82
+ /**
83
+ * Caps every snapshot at the (indexed) per-occurrence limit for its accident
84
+ * year: capped incurred = min(reported incurred, cap_y); capped paid =
85
+ * min(paid, cap_y); capped case = capped incurred - capped paid (never
86
+ * negative by construction). Claim counts and statuses are untouched - the
87
+ * cap limits dollars, not claims.
88
+ */
89
+ export function capClaims(claims: ClaimSnapshot[], options: CapOptions): ClaimSnapshot[] {
90
+ const resolved = resolveOptions(claims, options);
91
+ return claims.map((snap) => {
92
+ const capY = effectiveCap(resolved, accidentYear(snap));
93
+ const incurred = snap.paidToDate + snap.caseReserve;
94
+ const cappedIncurred = Math.min(incurred, capY);
95
+ const cappedPaid = Math.min(snap.paidToDate, capY);
96
+ if (cappedIncurred === incurred && cappedPaid === snap.paidToDate) return snap;
97
+ return {
98
+ ...snap,
99
+ paidToDate: cappedPaid,
100
+ caseReserve: cappedIncurred - cappedPaid,
101
+ };
102
+ });
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Cap-selection diagnostics: the evidence an actuary picks the layer from.
107
+
108
+ export interface ClaimSizeYearRow {
109
+ year: number;
110
+ claimCount: number;
111
+ /** Reported incurred at each claim's latest evaluation. */
112
+ totalIncurred: number;
113
+ maxClaim: number;
114
+ percentiles: { p: number; value: number }[];
115
+ }
116
+
117
+ export interface CapCandidateYearCell {
118
+ year: number;
119
+ /** The candidate cap at this year's cost level (indexed). */
120
+ effectiveCap: number;
121
+ /** Claims whose reported incurred pierces the effective cap. */
122
+ pierceCount: number;
123
+ pierceShare: number;
124
+ /** Dollars above the cap as a share of total reported incurred. */
125
+ excessShare: number;
126
+ }
127
+
128
+ export interface CapCandidate {
129
+ /** Candidate cap stated at the base-year cost level. */
130
+ cap: number;
131
+ byYear: CapCandidateYearCell[];
132
+ totalPierceCount: number;
133
+ totalPierceShare: number;
134
+ totalExcessShare: number;
135
+ }
136
+
137
+ export interface ClaimSizeDiagnostics {
138
+ years: ClaimSizeYearRow[];
139
+ candidates: CapCandidate[];
140
+ baseYear: number;
141
+ indexRate: number;
142
+ /** Claims with a positive reported incurred at their latest evaluation. */
143
+ nonZeroClaimCount: number;
144
+ }
145
+
146
+ export interface ClaimSizeDiagnosticsOptions {
147
+ /** Only snapshots evaluated on or before this ISO date are considered. */
148
+ asOfDate?: string;
149
+ /** Candidate caps stated at the base-year level; sensible defaults derived if omitted. */
150
+ candidateCaps?: number[];
151
+ /** Caps merged into the derived defaults (e.g. the currently-set cap). */
152
+ extraCaps?: number[];
153
+ indexRate?: number;
154
+ baseYear?: number;
155
+ }
156
+
157
+ const PERCENTILES = [0.5, 0.75, 0.9, 0.95, 0.99];
158
+
159
+ // Crude empirical convention (ceil-rank, no interpolation) is deliberate:
160
+ // candidate caps get rounded to 1-2 significant digits right after, so the
161
+ // interpolated refinement in stochastic.ts's percentileOfSorted buys nothing.
162
+ function percentile(sorted: number[], p: number): number {
163
+ if (sorted.length === 0) return 0;
164
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1));
165
+ return sorted[idx]!;
166
+ }
167
+
168
+ /** Round to 1-2 significant digits for readable candidate caps. */
169
+ function roundCandidate(value: number): number {
170
+ if (value <= 0) return 0;
171
+ const magnitude = Math.pow(10, Math.floor(Math.log10(value)));
172
+ const scaled = value / magnitude;
173
+ const nice = scaled < 1.5 ? 1 : scaled < 3.5 ? 2.5 : scaled < 7.5 ? 5 : 10;
174
+ return nice * magnitude;
175
+ }
176
+
177
+ /**
178
+ * Latest-evaluation claim-size distribution by accident year plus pierce and
179
+ * excess-share statistics for a set of candidate caps (indexed to each year).
180
+ */
181
+ export function claimSizeDiagnostics(
182
+ claims: ClaimSnapshot[],
183
+ options: ClaimSizeDiagnosticsOptions = {},
184
+ ): ClaimSizeDiagnostics {
185
+ if (claims.length === 0) {
186
+ throw new ReservingError("NO_CLAIMS", "Cannot analyze claim sizes with no claims");
187
+ }
188
+ const asOf = options.asOfDate ?? "9999-12-31";
189
+ // Latest evaluation per claim on or before the analysis date.
190
+ const latest = new Map<string, ClaimSnapshot>();
191
+ for (const snap of claims) {
192
+ if (snap.evaluationDate > asOf) continue;
193
+ const prev = latest.get(snap.claimId);
194
+ if (!prev || snap.evaluationDate > prev.evaluationDate) latest.set(snap.claimId, snap);
195
+ }
196
+
197
+ const byYear = new Map<number, number[]>();
198
+ for (const snap of latest.values()) {
199
+ const year = accidentYear(snap);
200
+ const incurred = snap.paidToDate + snap.caseReserve;
201
+ let arr = byYear.get(year);
202
+ if (!arr) byYear.set(year, (arr = []));
203
+ arr.push(incurred);
204
+ }
205
+ const years = [...byYear.keys()].sort((a, b) => a - b);
206
+ const indexRate = options.indexRate ?? 0;
207
+ const baseYear = options.baseYear ?? years[years.length - 1]!;
208
+
209
+ const yearRows: ClaimSizeYearRow[] = years.map((year) => {
210
+ const sorted = [...byYear.get(year)!].sort((a, b) => a - b);
211
+ return {
212
+ year,
213
+ claimCount: sorted.length,
214
+ totalIncurred: sorted.reduce((a, v) => a + v, 0),
215
+ maxClaim: sorted[sorted.length - 1] ?? 0,
216
+ percentiles: PERCENTILES.map((p) => ({ p, value: percentile(sorted, p) })),
217
+ };
218
+ });
219
+
220
+ // Candidate caps: provided, or derived from the pooled distribution so the
221
+ // menu spans "caps almost nothing" to "caps only the extreme tail".
222
+ // Candidates are STATED at the base-year cost level, so each claim's
223
+ // incurred is deflated to that level before taking percentiles - otherwise
224
+ // a nominal anchor re-indexed forward overshoots the whole distribution
225
+ // and every derived candidate reads near-zero pierce.
226
+ let candidateCaps = options.candidateCaps;
227
+ if (!candidateCaps || candidateCaps.length === 0) {
228
+ const pooled = [...latest.values()]
229
+ .map((s) => {
230
+ const incurred = s.paidToDate + s.caseReserve;
231
+ return incurred / Math.pow(1 + indexRate, accidentYear(s) - baseYear);
232
+ })
233
+ .filter((v) => v > 0)
234
+ .sort((a, b) => a - b);
235
+ const anchors = [0.75, 0.9, 0.95, 0.99].map((p) => percentile(pooled, p));
236
+ const uniq = new Set<number>();
237
+ for (const a of anchors) {
238
+ const rounded = roundCandidate(a);
239
+ if (rounded > 0) uniq.add(rounded);
240
+ }
241
+ for (const extra of options.extraCaps ?? []) {
242
+ if (extra > 0) uniq.add(extra);
243
+ }
244
+ candidateCaps = [...uniq].sort((a, b) => a - b);
245
+ }
246
+
247
+ const resolved: Required<CapOptions> = { cap: 1, indexRate, baseYear };
248
+ const candidates: CapCandidate[] = candidateCaps.map((cap) => {
249
+ let totalPierce = 0;
250
+ let totalClaims = 0;
251
+ let totalExcess = 0;
252
+ let totalIncurred = 0;
253
+ const cells: CapCandidateYearCell[] = years.map((year) => {
254
+ const capY = cap * Math.pow(1 + resolved.indexRate, year - resolved.baseYear);
255
+ const values = byYear.get(year)!;
256
+ let pierce = 0;
257
+ let excess = 0;
258
+ let incurred = 0;
259
+ for (const v of values) {
260
+ incurred += v;
261
+ if (v > capY) {
262
+ pierce++;
263
+ excess += v - capY;
264
+ }
265
+ }
266
+ totalPierce += pierce;
267
+ totalClaims += values.length;
268
+ totalExcess += excess;
269
+ totalIncurred += incurred;
270
+ return {
271
+ year,
272
+ effectiveCap: capY,
273
+ pierceCount: pierce,
274
+ pierceShare: values.length > 0 ? pierce / values.length : 0,
275
+ excessShare: incurred > 0 ? excess / incurred : 0,
276
+ };
277
+ });
278
+ return {
279
+ cap,
280
+ byYear: cells,
281
+ totalPierceCount: totalPierce,
282
+ totalPierceShare: totalClaims > 0 ? totalPierce / totalClaims : 0,
283
+ totalExcessShare: totalIncurred > 0 ? totalExcess / totalIncurred : 0,
284
+ };
285
+ });
286
+
287
+ return {
288
+ years: yearRows,
289
+ candidates,
290
+ baseYear,
291
+ indexRate,
292
+ nonZeroClaimCount: [...latest.values()].filter((s) => s.paidToDate + s.caseReserve > 0)
293
+ .length,
294
+ };
295
+ }
@@ -0,0 +1,270 @@
1
+ import type { Triangle } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum, lastJointObservedIndex, lastObservedIndex, safeRatio } from "./util.js";
4
+
5
+ /**
6
+ * Case-outstanding development technique (Friedland, "Estimating Unpaid
7
+ * Claims Using Basic Techniques", ch. 12): for books where the case reserves
8
+ * are the only reliable triangle (classic self-insured situation), unpaid
9
+ * claims are projected from the run-off of the case outstanding itself.
10
+ *
11
+ * Ground truth:
12
+ * - Two historical ratio triangles drive the review:
13
+ * caseRatios[i][j] = case[i][j+1] / case[i][j] (case run-off), and
14
+ * paidOnPriorCase[i][j] = (paid[i][j+1] - paid[i][j]) / case[i][j]
15
+ * (incremental paid in the interval as a ratio of the case reserve held at
16
+ * its start - "paid on prior case").
17
+ * - The CALLER selects both patterns (this module never picks factors). The
18
+ * projection walks each origin's latest case forward: payments in the next
19
+ * interval = case x paidOnCaseSelection, next case = case x caseSelection.
20
+ * - Case remaining at the last development age pays out at `tailPaidOnCase`
21
+ * times its face value (default 1: paid in full, the conventional
22
+ * assumption; a warning notes when the default was relied on).
23
+ * - unpaid = the sum of the projected future payments including the tail
24
+ * payout - the reserve IS the projected paid stream, an identity the
25
+ * self-consistency test enforces.
26
+ *
27
+ * Selection coercions (loud, never silent):
28
+ * - missing case selection -> 1.000 (case carried forward), warned;
29
+ * - negative case selection -> 0.000 (case cannot go negative), warned;
30
+ * - missing paid-on-case selection -> 0.000 (no payment projected), warned;
31
+ * - negative paid-on-case selection -> kept (projects net recoveries), warned;
32
+ * - ALL selections missing across both arrays -> NO_SELECTIONS.
33
+ */
34
+
35
+ export interface CaseOutstandingOptions {
36
+ /** Selected case run-off ratios (next case / current case), one per interval. */
37
+ caseSelections: (number | null)[];
38
+ /** Selected incremental-paid-to-prior-case ratios, one per interval. */
39
+ paidOnCaseSelections: (number | null)[];
40
+ /**
41
+ * Payments after the last age as a ratio of the case remaining there.
42
+ * Default 1 (remaining case pays out in full).
43
+ */
44
+ tailPaidOnCase?: number;
45
+ }
46
+
47
+ export interface CaseOutstandingRow {
48
+ origin: string;
49
+ /** Age (months) of the latest cell observed in BOTH triangles. */
50
+ latestAge: number;
51
+ paidToDate: number;
52
+ /** Case outstanding at the latest age (the projection's seed). */
53
+ caseOutstanding: number;
54
+ /**
55
+ * Projected future incremental paid: futurePaid[m] pays in the interval
56
+ * ending at the (m+1)-th age after latestAge; the LAST element is the tail
57
+ * payout of the case remaining at the final development age.
58
+ */
59
+ futurePaid: number[];
60
+ /** Projected case outstanding at each later age (parallel run-off path). */
61
+ projectedCase: number[];
62
+ /** Sum of futurePaid (the reserve). */
63
+ unpaid: number;
64
+ /** paidToDate + unpaid. */
65
+ ultimate: number;
66
+ }
67
+
68
+ export interface CaseOutstandingResult {
69
+ method: "caseOutstanding";
70
+ /** Historical case run-off ratios, [origin][interval]; null where not computable. */
71
+ caseRatios: (number | null)[][];
72
+ /** Historical incremental paid / prior case, [origin][interval]. */
73
+ paidOnPriorCase: (number | null)[][];
74
+ /** The tail payout ratio the projection used. */
75
+ tailPaidOnCase: number;
76
+ rows: CaseOutstandingRow[];
77
+ totals: { paidToDate: number; caseOutstanding: number; unpaid: number; ultimate: number };
78
+ warnings: string[];
79
+ }
80
+
81
+ function assertSameShape(a: Triangle, b: Triangle): void {
82
+ const sameOrigins =
83
+ a.origins.length === b.origins.length && a.origins.every((o, i) => o === b.origins[i]);
84
+ const sameAges = a.ages.length === b.ages.length && a.ages.every((v, j) => v === b.ages[j]);
85
+ if (!sameOrigins || !sameAges) {
86
+ throw new ReservingError(
87
+ "SHAPE",
88
+ "The paid and case-outstanding triangles must share identical origins and development ages",
89
+ );
90
+ }
91
+ }
92
+
93
+ export function runCaseOutstanding(
94
+ paid: Triangle,
95
+ caseOutstanding: Triangle,
96
+ options: CaseOutstandingOptions,
97
+ ): CaseOutstandingResult {
98
+ assertSameShape(paid, caseOutstanding);
99
+ const nOrigins = paid.origins.length;
100
+ const nAges = paid.ages.length;
101
+ const nIntervals = nAges - 1;
102
+ for (const [name, selections] of [
103
+ ["caseSelections", options.caseSelections],
104
+ ["paidOnCaseSelections", options.paidOnCaseSelections],
105
+ ] as const) {
106
+ if (selections.length !== nIntervals) {
107
+ throw new ReservingError(
108
+ "SELECTION_SHAPE",
109
+ `Expected ${nIntervals} ${name} (one per development interval), got ${selections.length}`,
110
+ );
111
+ }
112
+ }
113
+ const tailPaidOnCase = options.tailPaidOnCase ?? 1;
114
+ if (!isNum(tailPaidOnCase) || tailPaidOnCase < 0) {
115
+ throw new ReservingError(
116
+ "BAD_TAIL",
117
+ `tailPaidOnCase must be a finite, non-negative ratio, got ${options.tailPaidOnCase}`,
118
+ );
119
+ }
120
+ const anySelected =
121
+ options.caseSelections.some((s) => isNum(s)) ||
122
+ options.paidOnCaseSelections.some((s) => isNum(s));
123
+ if (nIntervals > 0 && !anySelected) {
124
+ throw new ReservingError(
125
+ "NO_SELECTIONS",
126
+ "No case run-off or paid-on-case ratios are selected for any interval; select patterns before running the analysis",
127
+ );
128
+ }
129
+
130
+ const warnings: string[] = [];
131
+
132
+ // Effective selections with the documented coercions, warned per interval.
133
+ const effectiveCase: number[] = options.caseSelections.map((s, j) => {
134
+ const label = `${paid.ages[j]}-${paid.ages[j + 1]} months`;
135
+ if (!isNum(s)) {
136
+ warnings.push(`Missing case run-off selection for ${label}; treated as 1.000 (case carried forward)`);
137
+ return 1;
138
+ }
139
+ if (s < 0) {
140
+ warnings.push(
141
+ `Negative case run-off selection for ${label} would project negative case; treated as 0.000`,
142
+ );
143
+ return 0;
144
+ }
145
+ return s;
146
+ });
147
+ const effectivePaid: number[] = options.paidOnCaseSelections.map((s, j) => {
148
+ const label = `${paid.ages[j]}-${paid.ages[j + 1]} months`;
149
+ if (!isNum(s)) {
150
+ warnings.push(`Missing paid-on-case selection for ${label}; treated as 0.000 (no payment projected)`);
151
+ return 0;
152
+ }
153
+ if (s < 0) {
154
+ warnings.push(`Negative paid-on-case selection for ${label} projects net recoveries; kept as given`);
155
+ }
156
+ return s;
157
+ });
158
+
159
+ // Historical ratio triangles (null-safe: prior case must be positive, and
160
+ // an incremental paid needs both cumulative cells observed).
161
+ const caseRatios: (number | null)[][] = [];
162
+ const paidOnPriorCase: (number | null)[][] = [];
163
+ for (let i = 0; i < nOrigins; i++) {
164
+ const cRow: (number | null)[] = [];
165
+ const pRow: (number | null)[] = [];
166
+ for (let j = 0; j < nIntervals; j++) {
167
+ const casePrior = caseOutstanding.values[i]![j] ?? null;
168
+ const caseNext = caseOutstanding.values[i]![j + 1] ?? null;
169
+ const paidPrior = paid.values[i]![j] ?? null;
170
+ const paidNext = paid.values[i]![j + 1] ?? null;
171
+ cRow.push(safeRatio(caseNext, casePrior));
172
+ pRow.push(
173
+ isNum(paidPrior) && isNum(paidNext)
174
+ ? safeRatio(paidNext - paidPrior, casePrior)
175
+ : null,
176
+ );
177
+ }
178
+ caseRatios.push(cRow);
179
+ paidOnPriorCase.push(pRow);
180
+ }
181
+
182
+ const rows: CaseOutstandingRow[] = [];
183
+ let tailDefaultRelevant = false;
184
+ for (let i = 0; i < nOrigins; i++) {
185
+ const paidRow = paid.values[i]!;
186
+ const caseRow = caseOutstanding.values[i]!;
187
+ const lastPaid = lastObservedIndex(paidRow);
188
+ const lastCase = lastObservedIndex(caseRow);
189
+ const k = lastJointObservedIndex(paidRow, caseRow);
190
+ if (k < 0) {
191
+ warnings.push(
192
+ `Origin ${paid.origins[i]} has no age with both paid and case outstanding observed; excluded from results`,
193
+ );
194
+ continue;
195
+ }
196
+ if (lastPaid !== lastCase) {
197
+ warnings.push(
198
+ `Origin ${paid.origins[i]}: paid and case diagonals end at different ages (${paid.ages[lastPaid]} vs ${paid.ages[lastCase]}); projected from the latest jointly observed age ${paid.ages[k]}`,
199
+ );
200
+ }
201
+
202
+ const paidToDate = paidRow[k]!;
203
+ const seedCase = caseRow[k]!;
204
+ if (seedCase < 0) {
205
+ warnings.push(
206
+ `Origin ${paid.origins[i]}: case outstanding at the seed age is negative (${seedCase}); the projection carries the sign through, so review whether net recoveries are genuinely expected`,
207
+ );
208
+ }
209
+ const futurePaid: number[] = [];
210
+ const projectedCase: number[] = [];
211
+ let current = seedCase;
212
+ let warnedNegativeProjection = seedCase < 0;
213
+ for (let j = k; j < nIntervals; j++) {
214
+ futurePaid.push(current * effectivePaid[j]!);
215
+ current *= effectiveCase[j]!;
216
+ projectedCase.push(current);
217
+ if (current < 0 && !warnedNegativeProjection) {
218
+ warnedNegativeProjection = true;
219
+ warnings.push(
220
+ `Origin ${paid.origins[i]}: projected case outstanding turns negative (${current}) at age ${paid.ages[j + 1] ?? "tail"}; downstream reserves inherit the sign`,
221
+ );
222
+ }
223
+ }
224
+ if (current !== 0 && options.tailPaidOnCase === undefined) tailDefaultRelevant = true;
225
+ futurePaid.push(current * tailPaidOnCase);
226
+
227
+ const unpaid = futurePaid.reduce((a, v) => a + v, 0);
228
+ rows.push({
229
+ origin: paid.origins[i]!,
230
+ latestAge: paid.ages[k]!,
231
+ paidToDate,
232
+ caseOutstanding: seedCase,
233
+ futurePaid,
234
+ projectedCase,
235
+ unpaid,
236
+ ultimate: paidToDate + unpaid,
237
+ });
238
+ }
239
+ if (rows.length === 0) {
240
+ throw new ReservingError(
241
+ "NO_DATA",
242
+ "No origin period has both paid and case outstanding observed at any age",
243
+ );
244
+ }
245
+ if (tailDefaultRelevant) {
246
+ warnings.push(
247
+ `Case outstanding remaining at ${paid.ages[nAges - 1]} months is assumed paid in full (tailPaidOnCase defaulted to 1); supply tailPaidOnCase to select otherwise`,
248
+ );
249
+ }
250
+
251
+ const totals = rows.reduce(
252
+ (acc, r) => ({
253
+ paidToDate: acc.paidToDate + r.paidToDate,
254
+ caseOutstanding: acc.caseOutstanding + r.caseOutstanding,
255
+ unpaid: acc.unpaid + r.unpaid,
256
+ ultimate: acc.ultimate + r.ultimate,
257
+ }),
258
+ { paidToDate: 0, caseOutstanding: 0, unpaid: 0, ultimate: 0 },
259
+ );
260
+
261
+ return {
262
+ method: "caseOutstanding",
263
+ caseRatios,
264
+ paidOnPriorCase,
265
+ tailPaidOnCase,
266
+ rows,
267
+ totals,
268
+ warnings,
269
+ };
270
+ }
@@ -0,0 +1,101 @@
1
+ import type { ChainLadderResult, ChainLadderRow, LdfSelections, Triangle } from "./types.js";
2
+ import { ReservingError } from "./types.js";
3
+ import { isNum, lastObservedIndex } from "./util.js";
4
+
5
+ /**
6
+ * Chain Ladder (development) method.
7
+ *
8
+ * Ground truth:
9
+ * - CDFs are computed right to left: CDF at the last observed age = tail
10
+ * factor; CDF_j = LDF_j x CDF_{j+1}.
11
+ * - Percent developed = 1 / CDF.
12
+ * - Ultimate = latest diagonal value x CDF at its age.
13
+ * - IBNR (incurred basis) / unpaid (paid basis) = ultimate - current.
14
+ * - Missing LDF selections: warn loudly and treat as 1.0; refuse to run when
15
+ * ALL selections are missing.
16
+ */
17
+ export function runChainLadder(tri: Triangle, selections: LdfSelections): ChainLadderResult {
18
+ const nAges = tri.ages.length;
19
+ const nLdfs = nAges - 1;
20
+ if (selections.selected.length !== nLdfs) {
21
+ throw new ReservingError(
22
+ "SELECTION_SHAPE",
23
+ `Expected ${nLdfs} LDF selections (one per development interval), got ${selections.selected.length}`,
24
+ );
25
+ }
26
+ if (!isNum(selections.tailFactor) || selections.tailFactor <= 0) {
27
+ throw new ReservingError("BAD_TAIL", "Tail factor must be a positive number");
28
+ }
29
+
30
+ const warnings: string[] = [];
31
+ const anySelected = selections.selected.some((s) => isNum(s));
32
+ if (nLdfs > 0 && !anySelected) {
33
+ throw new ReservingError(
34
+ "NO_SELECTIONS",
35
+ "No LDFs are selected for any development interval; select factors before running the analysis",
36
+ );
37
+ }
38
+
39
+ const effective: number[] = selections.selected.map((s, j) => {
40
+ if (isNum(s)) {
41
+ if (s <= 0) {
42
+ warnings.push(
43
+ `Selected LDF for ${tri.ages[j]}-${tri.ages[j + 1]} months is not positive; treated as 1.000`,
44
+ );
45
+ return 1;
46
+ }
47
+ return s;
48
+ }
49
+ warnings.push(
50
+ `Missing LDF selection for ${tri.ages[j]}-${tri.ages[j + 1]} months; treated as 1.000`,
51
+ );
52
+ return 1;
53
+ });
54
+
55
+ // Right-to-left cumulative development factors; cdfs[j] develops age[j] to ultimate.
56
+ const cdfs: number[] = new Array(nAges).fill(selections.tailFactor);
57
+ for (let j = nAges - 2; j >= 0; j--) {
58
+ cdfs[j] = effective[j]! * cdfs[j + 1]!;
59
+ }
60
+ const percentDeveloped = cdfs.map((c) => 1 / c);
61
+
62
+ const rows: ChainLadderRow[] = [];
63
+ for (let i = 0; i < tri.origins.length; i++) {
64
+ const latestIdx = lastObservedIndex(tri.values[i]!);
65
+ if (latestIdx < 0) {
66
+ warnings.push(`Origin ${tri.origins[i]} has no observed values; excluded from results`);
67
+ continue;
68
+ }
69
+ const latestValue = tri.values[i]![latestIdx]!;
70
+ const cdf = cdfs[latestIdx]!;
71
+ const ultimate = latestValue * cdf;
72
+ rows.push({
73
+ origin: tri.origins[i]!,
74
+ latestAge: tri.ages[latestIdx]!,
75
+ latestValue,
76
+ cdf,
77
+ percentDeveloped: 1 / cdf,
78
+ ultimate,
79
+ unpaid: ultimate - latestValue,
80
+ });
81
+ }
82
+
83
+ const totals = rows.reduce(
84
+ (acc, r) => ({
85
+ latest: acc.latest + r.latestValue,
86
+ ultimate: acc.ultimate + r.ultimate,
87
+ unpaid: acc.unpaid + r.unpaid,
88
+ }),
89
+ { latest: 0, ultimate: 0, unpaid: 0 },
90
+ );
91
+
92
+ return {
93
+ method: "chainLadder",
94
+ basis: tri.kind,
95
+ cdfs,
96
+ percentDeveloped,
97
+ rows,
98
+ totals,
99
+ warnings,
100
+ };
101
+ }