@holoscript/plugin-insurance 2.0.1

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.
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Actuarial math solver — insurance-plugin
3
+ *
4
+ * Implements standard actuarial calculations without external dependencies:
5
+ * - Life-table survival functions (qx / lx / dx / Lx / Tx / ex)
6
+ * - Net single premium (NSP) for whole-life, term-life, endowment contracts
7
+ * - Level annual premium from equivalence principle
8
+ * - Net Present Value of a cash-flow stream
9
+ * - Value at Risk (VaR) and Conditional VaR (CVaR) via sorted loss vector
10
+ * - Bonus: Gompertz-Makeham mortality model for parameter fitting
11
+ * - CAEL-backed receipt
12
+ *
13
+ * References:
14
+ * Bowers et al. "Actuarial Mathematics" (2nd ed., 1997) — SOA study text.
15
+ * London, D. "Survival Models and Their Estimation" (3rd ed., 1997).
16
+ */
17
+
18
+ import {
19
+ DOMAIN_SIMULATION_RECEIPT_SCHEMA,
20
+ buildDomainSimulationReceipt,
21
+ } from '@holoscript/core';
22
+
23
+ // ─── Types ────────────────────────────────────────────────────────────────────
24
+
25
+ /** One row of a standard life table (age x). */
26
+ export interface LifeTableRow {
27
+ age: number; // x — integer age
28
+ qx: number; // probability of death between age x and x+1
29
+ lx: number; // number of survivors at age x (radix l₀ = 100 000)
30
+ dx: number; // deaths between x and x+1
31
+ Lx: number; // person-years lived between x and x+1
32
+ Tx: number; // total person-years lived above age x
33
+ ex: number; // curtate life expectancy at age x
34
+ }
35
+
36
+ /** Abridged model life table — minimum required: age 0 through at least age 100. */
37
+ export interface LifeTable {
38
+ id: string;
39
+ rows: LifeTableRow[];
40
+ /** Annual effective interest rate (e.g. 0.05 = 5%) */
41
+ interestRate: number;
42
+ }
43
+
44
+ export type PolicyType = 'whole_life' | 'term_life' | 'endowment' | 'annuity_due';
45
+
46
+ export interface PolicySpec {
47
+ type: PolicyType;
48
+ issueAge: number; // age at policy issue
49
+ termYears?: number; // required for term_life and endowment
50
+ benefitAmount: number; // face amount (currency units)
51
+ /** Annual effective interest rate override; falls back to table default */
52
+ interestRate?: number;
53
+ }
54
+
55
+ export interface ActuarialResult {
56
+ /** Net Single Premium — expected present value of benefits */
57
+ nsp: number;
58
+ /** Level annual premium (equivalence principle) */
59
+ annualPremium: number;
60
+ /** Curtate life expectancy at issue age */
61
+ lifeExpectancy: number;
62
+ /** Present value of an annuity-due of 1 per year at issue age */
63
+ annuityDue: number;
64
+ /** Policy type */
65
+ policyType: PolicyType;
66
+ issueAge: number;
67
+ termYears: number | null;
68
+ }
69
+
70
+ export interface CashFlow {
71
+ /** Period index (0-based; typically year number) */
72
+ period: number;
73
+ /** Cash inflow (+) or outflow (−) */
74
+ amount: number;
75
+ }
76
+
77
+ export interface NPVResult {
78
+ npv: number;
79
+ irr: number | null; // null if no real IRR found in 0–200% range
80
+ paybackPeriod: number | null; // period where cumulative CF first ≥ 0
81
+ }
82
+
83
+ export interface VaRResult {
84
+ confidenceLevel: number; // e.g. 0.95
85
+ var95: number; // VaR at 95%
86
+ var99: number; // VaR at 99%
87
+ cvar95: number; // CVaR (expected shortfall) at 95%
88
+ cvar99: number; // CVaR at 99%
89
+ expectedLoss: number;
90
+ maxLoss: number;
91
+ sampleSize: number;
92
+ }
93
+
94
+ export interface ActuarialReceipt {
95
+ plugin: string;
96
+ runId: string;
97
+ payloadHash: string;
98
+ hashAlgorithm: string;
99
+ cael: { event: string; schemaVersion: string; ts: string };
100
+ acceptance: { accepted: boolean; violations: string[] };
101
+ resultSummary: {
102
+ nsp: number;
103
+ annualPremium: number;
104
+ lifeExpectancy: number;
105
+ };
106
+ }
107
+
108
+ // ─── Life table construction ──────────────────────────────────────────────────
109
+
110
+ const RADIX = 100_000;
111
+
112
+ /**
113
+ * Build a complete life table from an array of qx values (one per age starting at 0).
114
+ * Follows standard actuarial convention: lx, dx, Lx, Tx, ex.
115
+ */
116
+ export function buildLifeTable(id: string, qxByAge: number[], interestRate: number): LifeTable {
117
+ if (qxByAge.length < 2) throw new Error('[actuarial] qxByAge must have at least 2 entries');
118
+ if (qxByAge.some((q) => q < 0 || q > 1)) throw new Error('[actuarial] all qx values must be in [0,1]');
119
+ if (interestRate < 0) throw new Error('[actuarial] interestRate must be ≥ 0');
120
+
121
+ const rows: LifeTableRow[] = [];
122
+ const lx_arr: number[] = [RADIX];
123
+ const dx_arr: number[] = [];
124
+ const Lx_arr: number[] = [];
125
+ const Tx_arr: number[] = [];
126
+ const ex_arr: number[] = [];
127
+
128
+ const n = qxByAge.length;
129
+
130
+ // Compute lx and dx
131
+ for (let x = 0; x < n; x++) {
132
+ const l = lx_arr[x];
133
+ const d = l * qxByAge[x];
134
+ dx_arr.push(d);
135
+ lx_arr.push(l - d);
136
+ }
137
+
138
+ // Compute Lx (person-years lived; trapezoidal approximation)
139
+ for (let x = 0; x < n; x++) {
140
+ Lx_arr.push((lx_arr[x] + lx_arr[x + 1]) / 2);
141
+ }
142
+
143
+ // Compute Tx (cumulative from top)
144
+ Tx_arr[n - 1] = Lx_arr[n - 1];
145
+ for (let x = n - 2; x >= 0; x--) {
146
+ Tx_arr[x] = Tx_arr[x + 1] + Lx_arr[x];
147
+ }
148
+
149
+ // Compute ex
150
+ for (let x = 0; x < n; x++) {
151
+ ex_arr.push(lx_arr[x] > 0 ? Tx_arr[x] / lx_arr[x] : 0);
152
+ }
153
+
154
+ for (let x = 0; x < n; x++) {
155
+ rows.push({
156
+ age: x,
157
+ qx: qxByAge[x],
158
+ lx: lx_arr[x],
159
+ dx: dx_arr[x],
160
+ Lx: Lx_arr[x],
161
+ Tx: Tx_arr[x],
162
+ ex: ex_arr[x],
163
+ });
164
+ }
165
+
166
+ return { id, rows, interestRate };
167
+ }
168
+
169
+ // ─── Actuarial commutation functions ─────────────────────────────────────────
170
+
171
+ /**
172
+ * Compute commutation columns Dx, Nx, Cx, Mx from a life table.
173
+ * These underlie all standard life insurance and annuity formulas.
174
+ *
175
+ * Dx = vˣ · lx (discounted survivors)
176
+ * Nx = Σ(x to ω) Dx (accumulated Dx)
177
+ * Cx = v^(x+1) · dx (discounted deaths)
178
+ * Mx = Σ(x to ω) Cx (accumulated Cx)
179
+ */
180
+ function commutationColumns(table: LifeTable, iRate: number): {
181
+ Dx: number[]; Nx: number[]; Cx: number[]; Mx: number[];
182
+ } {
183
+ const v = 1 / (1 + iRate);
184
+ const n = table.rows.length;
185
+ const Dx = table.rows.map((r) => Math.pow(v, r.age) * r.lx);
186
+ const Cx = table.rows.map((r) => Math.pow(v, r.age + 1) * r.dx);
187
+
188
+ const Nx: number[] = new Array(n).fill(0);
189
+ const Mx: number[] = new Array(n).fill(0);
190
+ Nx[n - 1] = Dx[n - 1];
191
+ Mx[n - 1] = Cx[n - 1];
192
+ for (let x = n - 2; x >= 0; x--) {
193
+ Nx[x] = Nx[x + 1] + Dx[x];
194
+ Mx[x] = Mx[x + 1] + Cx[x];
195
+ }
196
+
197
+ return { Dx, Nx, Cx, Mx };
198
+ }
199
+
200
+ // ─── Premium calculation ──────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Compute actuarial values for a policy using the equivalence principle.
204
+ *
205
+ * Policy types:
206
+ * whole_life — benefit B paid at end of year of death
207
+ * term_life — B paid at end of year of death if within n years
208
+ * endowment — B paid at earlier of death or survival to end of n years
209
+ * annuity_due — benefit B paid at START of each year while alive (no death benefit)
210
+ */
211
+ export function computeActuarialValues(spec: PolicySpec, table: LifeTable): ActuarialResult {
212
+ const n = table.rows.length;
213
+ const iRate = spec.interestRate ?? table.interestRate;
214
+ const x = spec.issueAge;
215
+ const B = spec.benefitAmount;
216
+
217
+ if (x < 0 || x >= n) throw new Error(`[actuarial] issueAge ${x} out of table range [0, ${n - 1}]`);
218
+ if (iRate < 0) throw new Error('[actuarial] interestRate must be ≥ 0');
219
+
220
+ const { Dx, Nx, Mx } = commutationColumns(table, iRate);
221
+
222
+ const row = table.rows[x];
223
+ const lifeExp = row.ex;
224
+
225
+ // ── Whole life ──────────────────────────────────────────────────────────────
226
+ if (spec.type === 'whole_life') {
227
+ const Ax = Mx[x] / Dx[x]; // net single premium per unit benefit
228
+ const annuity = Nx[x] / Dx[x]; // ä_x (annuity-due of 1)
229
+ const nsp = B * Ax;
230
+ const Px = Ax / annuity; // level premium per unit benefit per year
231
+ return {
232
+ nsp,
233
+ annualPremium: B * Px,
234
+ lifeExpectancy: lifeExp,
235
+ annuityDue: annuity,
236
+ policyType: 'whole_life',
237
+ issueAge: x,
238
+ termYears: null,
239
+ };
240
+ }
241
+
242
+ // ── Term life ───────────────────────────────────────────────────────────────
243
+ if (spec.type === 'term_life') {
244
+ const term = spec.termYears ?? 20;
245
+ const xn = Math.min(x + term, n - 1);
246
+ const A1xn = (Mx[x] - Mx[xn]) / Dx[x]; // A^1_{x:n} term insurance per unit
247
+ const axn = (Nx[x] - Nx[xn]) / Dx[x]; // ä_{x:n} term annuity-due per unit
248
+ const nsp = B * A1xn;
249
+ return {
250
+ nsp,
251
+ annualPremium: axn > 0 ? nsp / axn : 0,
252
+ lifeExpectancy: lifeExp,
253
+ annuityDue: axn,
254
+ policyType: 'term_life',
255
+ issueAge: x,
256
+ termYears: term,
257
+ };
258
+ }
259
+
260
+ // ── Endowment ───────────────────────────────────────────────────────────────
261
+ if (spec.type === 'endowment') {
262
+ const term = spec.termYears ?? 20;
263
+ const xn = Math.min(x + term, n - 1);
264
+ // Endowment = term life + pure endowment (survival to n)
265
+ const A1xn = (Mx[x] - Mx[xn]) / Dx[x]; // insurance component
266
+ const Exn = Dx[xn] / Dx[x]; // pure endowment (n Ex)
267
+ const Axn = A1xn + Exn; // endowment NSP per unit
268
+ const axn = (Nx[x] - Nx[xn]) / Dx[x];
269
+ const nsp = B * Axn;
270
+ return {
271
+ nsp,
272
+ annualPremium: axn > 0 ? nsp / axn : 0,
273
+ lifeExpectancy: lifeExp,
274
+ annuityDue: axn,
275
+ policyType: 'endowment',
276
+ issueAge: x,
277
+ termYears: term,
278
+ };
279
+ }
280
+
281
+ // ── Life annuity-due ────────────────────────────────────────────────────────
282
+ if (spec.type === 'annuity_due') {
283
+ const term = spec.termYears;
284
+ let annuityDue: number;
285
+ let termYears: number | null;
286
+
287
+ if (term != null) {
288
+ // Temporary life annuity-due ä_{x:n}
289
+ const xn = Math.min(x + term, n - 1);
290
+ annuityDue = (Nx[x] - Nx[xn]) / Dx[x];
291
+ termYears = term;
292
+ } else {
293
+ // Whole life annuity-due ä_x
294
+ annuityDue = Nx[x] / Dx[x];
295
+ termYears = null;
296
+ }
297
+
298
+ const nsp = B * annuityDue;
299
+ return {
300
+ nsp,
301
+ annualPremium: 0, // annuities don't have a separately stated premium
302
+ lifeExpectancy: lifeExp,
303
+ annuityDue,
304
+ policyType: 'annuity_due',
305
+ issueAge: x,
306
+ termYears,
307
+ };
308
+ }
309
+
310
+ throw new Error(`[actuarial] unknown policy type: ${(spec as PolicySpec).type}`);
311
+ }
312
+
313
+ // ─── NPV / IRR ────────────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Compute Net Present Value at a given discount rate.
317
+ * Also estimates IRR (via bisection, 0–200%) and payback period.
318
+ */
319
+ export function computeNPV(cashFlows: CashFlow[], discountRate: number): NPVResult {
320
+ if (cashFlows.length === 0) throw new Error('[actuarial] cashFlows must not be empty');
321
+ if (discountRate < 0) throw new Error('[actuarial] discountRate must be ≥ 0');
322
+
323
+ const sorted = [...cashFlows].sort((a, b) => a.period - b.period);
324
+
325
+ const npv = sorted.reduce((s, cf) => s + cf.amount / (1 + discountRate) ** cf.period, 0);
326
+
327
+ // Payback period: earliest period where cumulative undiscounted CF ≥ 0
328
+ let cum = 0;
329
+ let paybackPeriod: number | null = null;
330
+ for (const cf of sorted) {
331
+ cum += cf.amount;
332
+ if (cum >= 0 && paybackPeriod === null) { paybackPeriod = cf.period; }
333
+ }
334
+
335
+ // IRR via bisection (finds rate where NPV = 0)
336
+ let irr: number | null = null;
337
+ const npvAt = (r: number) => sorted.reduce((s, cf) => s + cf.amount / (1 + r) ** cf.period, 0);
338
+ const lo = 0, hi = 2.0; // 0% to 200%
339
+ if (Math.sign(npvAt(lo)) !== Math.sign(npvAt(hi))) {
340
+ let a = lo, b = hi;
341
+ for (let i = 0; i < 100; i++) {
342
+ const mid = (a + b) / 2;
343
+ if (Math.abs(b - a) < 1e-10) { irr = mid; break; }
344
+ Math.sign(npvAt(mid)) === Math.sign(npvAt(a)) ? (a = mid) : (b = mid);
345
+ irr = (a + b) / 2;
346
+ }
347
+ }
348
+
349
+ return { npv, irr, paybackPeriod };
350
+ }
351
+
352
+ // ─── Value at Risk ────────────────────────────────────────────────────────────
353
+
354
+ /**
355
+ * Compute historical VaR and CVaR from a vector of loss observations.
356
+ * Positive values = losses; negative values = gains.
357
+ */
358
+ export function computeVaR(losses: number[]): VaRResult {
359
+ if (losses.length < 2) throw new Error('[actuarial] VaR requires at least 2 loss observations');
360
+
361
+ const sorted = [...losses].sort((a, b) => a - b);
362
+ const n = sorted.length;
363
+
364
+ const idx95 = Math.ceil(0.95 * n) - 1;
365
+ const idx99 = Math.ceil(0.99 * n) - 1;
366
+
367
+ const var95 = sorted[idx95];
368
+ const var99 = sorted[idx99];
369
+
370
+ // CVaR: mean of losses beyond VaR threshold
371
+ const tail95 = sorted.slice(idx95);
372
+ const tail99 = sorted.slice(idx99);
373
+ const cvar95 = tail95.reduce((s, v) => s + v, 0) / tail95.length;
374
+ const cvar99 = tail99.reduce((s, v) => s + v, 0) / tail99.length;
375
+
376
+ const expectedLoss = sorted.reduce((s, v) => s + v, 0) / n;
377
+ const maxLoss = sorted[n - 1];
378
+
379
+ return {
380
+ confidenceLevel: 0.95,
381
+ var95,
382
+ var99,
383
+ cvar95,
384
+ cvar99,
385
+ expectedLoss,
386
+ maxLoss,
387
+ sampleSize: n,
388
+ };
389
+ }
390
+
391
+ // ─── Gompertz-Makeham mortality model ─────────────────────────────────────────
392
+
393
+ /**
394
+ * Gompertz-Makeham force of mortality: μ(x) = A + B·cˣ
395
+ *
396
+ * Parameters:
397
+ * A — background hazard (accident component)
398
+ * B — initial mortality rate at age 0 (aging component)
399
+ * c — rate of increase (c > 1 for increasing mortality)
400
+ *
401
+ * Returns qx values for ages 0 .. maxAge.
402
+ */
403
+ export function gompertzMakehamQx(params: { A: number; B: number; c: number }, maxAge: number): number[] {
404
+ const { A, B, c } = params;
405
+ if (B <= 0 || c <= 1) throw new Error('[actuarial] Gompertz params require B>0 and c>1');
406
+ const lnC = Math.log(c);
407
+ return Array.from({ length: maxAge + 1 }, (_, x) => {
408
+ // qx ≈ 1 − exp(−∫ₓˣ⁺¹ μ(t) dt) = 1 − exp(−A − B(cˣ)(c−1)/ln(c))
409
+ const integral = A + B * Math.pow(c, x) * (c - 1) / lnC;
410
+ return Math.min(1, 1 - Math.exp(-integral));
411
+ });
412
+ }
413
+
414
+ // ─── Receipt ─────────────────────────────────────────────────────────────────
415
+
416
+ export function buildActuarialReceipt(
417
+ result: ActuarialResult,
418
+ options?: { runId?: string },
419
+ ): ActuarialReceipt {
420
+ const violations: Array<{ criterion: string; message: string }> = [];
421
+ if (result.nsp < 0)
422
+ violations.push({ criterion: 'nsp', message: `negative NSP: ${result.nsp.toFixed(4)}` });
423
+ if (result.annualPremium < 0)
424
+ violations.push({ criterion: 'premium', message: `negative annual premium: ${result.annualPremium.toFixed(4)}` });
425
+
426
+ const raw = buildDomainSimulationReceipt({
427
+ plugin: 'insurance',
428
+ pluginVersion: '1.0.0',
429
+ runId: options?.runId ?? `act-${Date.now().toString(36)}`,
430
+ modelId: `${result.policyType}-age${result.issueAge}`,
431
+ solverConfig: {
432
+ solverType: 'commutation-columns',
433
+ scale: 'individual-policy',
434
+ policyType: result.policyType,
435
+ issueAge: result.issueAge,
436
+ },
437
+ resultSummary: {
438
+ nsp: +result.nsp.toFixed(4),
439
+ annualPremium: +result.annualPremium.toFixed(4),
440
+ lifeExpectancy: +result.lifeExpectancy.toFixed(2),
441
+ },
442
+ cael: {
443
+ version: 'cael.v1',
444
+ event: 'insurance.actuarial',
445
+ solverType: 'insurance.commutation-columns',
446
+ },
447
+ acceptance: { accepted: violations.length === 0, violations },
448
+ });
449
+
450
+ return raw as unknown as ActuarialReceipt;
451
+ }