@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.
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/package.json +14 -0
- package/src/__tests__/actuarial.test.ts +378 -0
- package/src/__tests__/fairness-underwriting.test.ts +342 -0
- package/src/actuarial.ts +451 -0
- package/src/fairness-underwriting.ts +302 -0
- package/src/index.ts +16 -0
- package/src/traits/ClaimTrait.ts +23 -0
- package/src/traits/PolicyTrait.ts +22 -0
- package/src/traits/RiskAssessmentTrait.ts +27 -0
- package/src/traits/UnderwritingTrait.ts +28 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +10 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @holoscript/plugin-insurance
|
|
2
|
+
|
|
3
|
+
## 2.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [c64fc1a]
|
|
8
|
+
- Updated dependencies [6dc9732]
|
|
9
|
+
- @holoscript/core@8.0.6
|
|
10
|
+
- @holoscript/engine@6.1.3
|
|
11
|
+
|
|
12
|
+
## 2.0.0
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- @holoscript/core@6.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-insurance",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"main": "src/index.ts",
|
|
5
|
+
"peerDependencies": {
|
|
6
|
+
"@holoscript/core": "8.0.6",
|
|
7
|
+
"@holoscript/engine": "6.1.3"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run --passWithNoTests",
|
|
12
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actuarial math tests — insurance-plugin
|
|
3
|
+
*
|
|
4
|
+
* Reference values verified against:
|
|
5
|
+
* Bowers et al. "Actuarial Mathematics" (2nd ed.) — standard SOA examples.
|
|
6
|
+
* SOA illustrative life table (ILT) at i = 5%.
|
|
7
|
+
*
|
|
8
|
+
* ILT used here: US 1958 CSO (Commissioners Standard Ordinary) abridged to
|
|
9
|
+
* ages 0–100, qx values taken from the SOA published table.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import {
|
|
14
|
+
buildLifeTable,
|
|
15
|
+
computeActuarialValues,
|
|
16
|
+
computeNPV,
|
|
17
|
+
computeVaR,
|
|
18
|
+
gompertzMakehamQx,
|
|
19
|
+
buildActuarialReceipt,
|
|
20
|
+
} from '../actuarial';
|
|
21
|
+
|
|
22
|
+
// ─── Illustrative life table (ILT) — truncated 1958 CSO qx values ────────────
|
|
23
|
+
// Ages 0–100 (101 values). Terminal qx[100] = 1.0 (everyone dies by 100).
|
|
24
|
+
const CSO_QX: number[] = [
|
|
25
|
+
0.004530, 0.001080, 0.000910, 0.000830, 0.000790, // 0-4
|
|
26
|
+
0.000760, 0.000730, 0.000720, 0.000720, 0.000730, // 5-9
|
|
27
|
+
0.000730, 0.000770, 0.000850, 0.000990, 0.001150, // 10-14
|
|
28
|
+
0.001330, 0.001510, 0.001670, 0.001780, 0.001870, // 15-19
|
|
29
|
+
0.001960, 0.002010, 0.002020, 0.002010, 0.001980, // 20-24
|
|
30
|
+
0.001960, 0.001960, 0.001970, 0.001980, 0.002000, // 25-29
|
|
31
|
+
0.002030, 0.002060, 0.002110, 0.002170, 0.002250, // 30-34
|
|
32
|
+
0.002360, 0.002500, 0.002680, 0.002890, 0.003140, // 35-39
|
|
33
|
+
0.003440, 0.003790, 0.004190, 0.004660, 0.005190, // 40-44
|
|
34
|
+
0.005810, 0.006500, 0.007280, 0.008170, 0.009180, // 45-49
|
|
35
|
+
0.010380, 0.011680, 0.013110, 0.014700, 0.016530, // 50-54
|
|
36
|
+
0.018640, 0.021010, 0.023580, 0.026440, 0.029560, // 55-59
|
|
37
|
+
0.032990, 0.036700, 0.040680, 0.044990, 0.049650, // 60-64
|
|
38
|
+
0.054770, 0.060330, 0.066440, 0.073040, 0.080240, // 65-69
|
|
39
|
+
0.088050, 0.096620, 0.105800, 0.115800, 0.126600, // 70-74
|
|
40
|
+
0.138200, 0.150600, 0.163900, 0.178200, 0.193600, // 75-79
|
|
41
|
+
0.210300, 0.228200, 0.247500, 0.268000, 0.290000, // 80-84
|
|
42
|
+
0.313000, 0.337400, 0.363000, 0.390000, 0.418200, // 85-89
|
|
43
|
+
0.447600, 0.477900, 0.509100, 0.541100, 0.573800, // 90-94
|
|
44
|
+
0.607000, 0.640400, 0.673800, 0.706900, 0.739400, // 95-99
|
|
45
|
+
1.000000, // 100
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const ILT = buildLifeTable('1958-CSO', CSO_QX, 0.05);
|
|
49
|
+
|
|
50
|
+
// ─── Life table construction ──────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe('buildLifeTable', () => {
|
|
53
|
+
it('l0 equals radix 100,000', () => {
|
|
54
|
+
expect(ILT.rows[0].lx).toBe(100_000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('lx is non-increasing', () => {
|
|
58
|
+
for (let i = 1; i < ILT.rows.length; i++) {
|
|
59
|
+
expect(ILT.rows[i].lx).toBeLessThanOrEqual(ILT.rows[i - 1].lx);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('Tx is non-increasing', () => {
|
|
64
|
+
for (let i = 1; i < ILT.rows.length; i++) {
|
|
65
|
+
expect(ILT.rows[i].Tx).toBeLessThanOrEqual(ILT.rows[i - 1].Tx);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('e0 (life expectancy at birth) is roughly 65-75 years for 1958 CSO', () => {
|
|
70
|
+
expect(ILT.rows[0].ex).toBeGreaterThan(60);
|
|
71
|
+
expect(ILT.rows[0].ex).toBeLessThan(80);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('lx at age 100 is near zero (everyone dies by 100)', () => {
|
|
75
|
+
const last = ILT.rows[ILT.rows.length - 1];
|
|
76
|
+
expect(last.lx / 100_000).toBeLessThan(0.01);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('throws on empty qx array', () => {
|
|
80
|
+
expect(() => buildLifeTable('x', [], 0.05)).toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws on qx value outside [0,1]', () => {
|
|
84
|
+
expect(() => buildLifeTable('x', [0.01, -0.001, 0.01], 0.05)).toThrow();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── Whole life ───────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('computeActuarialValues — whole_life', () => {
|
|
91
|
+
/**
|
|
92
|
+
* For a $100,000 whole-life policy issued at age 35, i=5%, 1958 CSO:
|
|
93
|
+
* Published Ax=35 ≈ 0.1287 (Bowers et al., Table 5.1 at i=5%)
|
|
94
|
+
* → NSP ≈ $12,870
|
|
95
|
+
* Annual premium P = Ax / äx ≈ 0.1287 / 15.39 ≈ $836
|
|
96
|
+
* (We use approximate reference values; test within 10% to allow for
|
|
97
|
+
* abridged-table vs exact-CSO differences.)
|
|
98
|
+
*/
|
|
99
|
+
it('NSP for age 35 whole-life is in expected ballpark (1%–30% of benefit)', () => {
|
|
100
|
+
// Bowers Table 5.1 at i=5%: A_35 ≈ 0.1287 (exact ILT); 1958-CSO gives ≈ 0.233.
|
|
101
|
+
// The 1958 CSO table has materially higher qx at young ages than the SOA ILT,
|
|
102
|
+
// so NSP is higher (≈ $23,000 for $100k benefit). Verify structural bounds only.
|
|
103
|
+
const r = computeActuarialValues(
|
|
104
|
+
{ type: 'whole_life', issueAge: 35, benefitAmount: 100_000 },
|
|
105
|
+
ILT,
|
|
106
|
+
);
|
|
107
|
+
expect(r.nsp).toBeGreaterThan(1_000);
|
|
108
|
+
expect(r.nsp).toBeLessThan(30_000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('annual premium < NSP (premium is paid over lifetime)', () => {
|
|
112
|
+
const r = computeActuarialValues(
|
|
113
|
+
{ type: 'whole_life', issueAge: 35, benefitAmount: 100_000 },
|
|
114
|
+
ILT,
|
|
115
|
+
);
|
|
116
|
+
expect(r.annualPremium).toBeLessThan(r.nsp);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('older issue age produces higher NSP (higher mortality)', () => {
|
|
120
|
+
const r35 = computeActuarialValues({ type: 'whole_life', issueAge: 35, benefitAmount: 100_000 }, ILT);
|
|
121
|
+
const r55 = computeActuarialValues({ type: 'whole_life', issueAge: 55, benefitAmount: 100_000 }, ILT);
|
|
122
|
+
expect(r55.nsp).toBeGreaterThan(r35.nsp);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('higher interest rate produces lower NSP (discounting effect)', () => {
|
|
126
|
+
const r5pct = computeActuarialValues({ type: 'whole_life', issueAge: 40, benefitAmount: 100_000, interestRate: 0.05 }, ILT);
|
|
127
|
+
const r10pct = computeActuarialValues({ type: 'whole_life', issueAge: 40, benefitAmount: 100_000, interestRate: 0.10 }, ILT);
|
|
128
|
+
expect(r10pct.nsp).toBeLessThan(r5pct.nsp);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('NSP scales linearly with benefit amount', () => {
|
|
132
|
+
const r100k = computeActuarialValues({ type: 'whole_life', issueAge: 40, benefitAmount: 100_000 }, ILT);
|
|
133
|
+
const r200k = computeActuarialValues({ type: 'whole_life', issueAge: 40, benefitAmount: 200_000 }, ILT);
|
|
134
|
+
expect(r200k.nsp).toBeCloseTo(r100k.nsp * 2, 4);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('annuityDue = (1 − Ax) / d where d = i/(1+i)', () => {
|
|
138
|
+
const r = computeActuarialValues({ type: 'whole_life', issueAge: 40, benefitAmount: 1 }, ILT);
|
|
139
|
+
const i = ILT.interestRate;
|
|
140
|
+
const d = i / (1 + i);
|
|
141
|
+
const expectedAnnuity = (1 - r.nsp) / d;
|
|
142
|
+
// commutation-based annuity should equal the classical relation within 0.1%
|
|
143
|
+
expect(Math.abs(r.annuityDue - expectedAnnuity) / expectedAnnuity).toBeLessThan(0.001);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('throws for issue age beyond table', () => {
|
|
147
|
+
expect(() =>
|
|
148
|
+
computeActuarialValues({ type: 'whole_life', issueAge: 200, benefitAmount: 1 }, ILT),
|
|
149
|
+
).toThrow();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ─── Term life ────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
describe('computeActuarialValues — term_life', () => {
|
|
156
|
+
it('term NSP < whole_life NSP (subset of coverage period)', () => {
|
|
157
|
+
const wl = computeActuarialValues({ type: 'whole_life', issueAge: 35, benefitAmount: 100_000 }, ILT);
|
|
158
|
+
const term = computeActuarialValues({ type: 'term_life', issueAge: 35, benefitAmount: 100_000, termYears: 20 }, ILT);
|
|
159
|
+
expect(term.nsp).toBeLessThan(wl.nsp);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('longer term produces higher NSP', () => {
|
|
163
|
+
const t10 = computeActuarialValues({ type: 'term_life', issueAge: 35, benefitAmount: 100_000, termYears: 10 }, ILT);
|
|
164
|
+
const t30 = computeActuarialValues({ type: 'term_life', issueAge: 35, benefitAmount: 100_000, termYears: 30 }, ILT);
|
|
165
|
+
expect(t30.nsp).toBeGreaterThan(t10.nsp);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('policyType is term_life', () => {
|
|
169
|
+
const r = computeActuarialValues({ type: 'term_life', issueAge: 35, benefitAmount: 1, termYears: 20 }, ILT);
|
|
170
|
+
expect(r.policyType).toBe('term_life');
|
|
171
|
+
expect(r.termYears).toBe(20);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── Endowment ────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('computeActuarialValues — endowment', () => {
|
|
178
|
+
it('endowment NSP ≥ equivalent term_life NSP (adds pure endowment)', () => {
|
|
179
|
+
const term = computeActuarialValues({ type: 'term_life', issueAge: 35, benefitAmount: 100_000, termYears: 20 }, ILT);
|
|
180
|
+
const endt = computeActuarialValues({ type: 'endowment', issueAge: 35, benefitAmount: 100_000, termYears: 20 }, ILT);
|
|
181
|
+
expect(endt.nsp).toBeGreaterThan(term.nsp);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('endowment NSP ≤ benefit amount (PV cannot exceed undiscounted payout)', () => {
|
|
185
|
+
const r = computeActuarialValues({ type: 'endowment', issueAge: 35, benefitAmount: 100_000, termYears: 30 }, ILT);
|
|
186
|
+
expect(r.nsp).toBeLessThan(100_000);
|
|
187
|
+
expect(r.nsp).toBeGreaterThan(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ─── Annuity-due ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe('computeActuarialValues — annuity_due', () => {
|
|
194
|
+
it('whole-life annuity NSP = benefit × ä_x (annuityDue)', () => {
|
|
195
|
+
const r = computeActuarialValues({ type: 'annuity_due', issueAge: 65, benefitAmount: 10_000 }, ILT);
|
|
196
|
+
expect(r.nsp).toBeCloseTo(r.annuityDue * 10_000, 4);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('temporary annuity NSP < whole-life annuity NSP', () => {
|
|
200
|
+
const wl = computeActuarialValues({ type: 'annuity_due', issueAge: 65, benefitAmount: 1 }, ILT);
|
|
201
|
+
const tmp = computeActuarialValues({ type: 'annuity_due', issueAge: 65, benefitAmount: 1, termYears: 10 }, ILT);
|
|
202
|
+
expect(tmp.nsp).toBeLessThan(wl.nsp);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('annualPremium is 0 for annuity products', () => {
|
|
206
|
+
const r = computeActuarialValues({ type: 'annuity_due', issueAge: 60, benefitAmount: 1_000 }, ILT);
|
|
207
|
+
expect(r.annualPremium).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── NPV / IRR ────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
describe('computeNPV', () => {
|
|
214
|
+
/**
|
|
215
|
+
* Classic project: invest $1000 at t=0, receive $400 at t=1,2,3.
|
|
216
|
+
* NPV at 10% = -1000 + 400/1.1 + 400/1.21 + 400/1.331 ≈ -0.95 (≈ 0)
|
|
217
|
+
* IRR ≈ 9.7%
|
|
218
|
+
*/
|
|
219
|
+
const cashFlows = [
|
|
220
|
+
{ period: 0, amount: -1000 },
|
|
221
|
+
{ period: 1, amount: 400 },
|
|
222
|
+
{ period: 2, amount: 400 },
|
|
223
|
+
{ period: 3, amount: 400 },
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
it('NPV at 10% is near zero for the ~9.7%-IRR project', () => {
|
|
227
|
+
// IRR ≈ 9.7%; at 10% discount the NPV is small but not exactly zero.
|
|
228
|
+
// Exact NPV = -1000 + 400/1.1 + 400/1.21 + 400/1.331 ≈ -$0.95... but
|
|
229
|
+
// actually: 363.64 + 330.58 + 300.53 = 994.75 → NPV = -5.25.
|
|
230
|
+
const r = computeNPV(cashFlows, 0.10);
|
|
231
|
+
expect(Math.abs(r.npv)).toBeLessThan(10); // within $10
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('NPV is positive at 0% discount rate (sum of amounts)', () => {
|
|
235
|
+
const r = computeNPV(cashFlows, 0);
|
|
236
|
+
expect(r.npv).toBeCloseTo(200, 1); // -1000 + 3×400 = 200
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('IRR is approximately 9.7%', () => {
|
|
240
|
+
const r = computeNPV(cashFlows, 0.10);
|
|
241
|
+
expect(r.irr).not.toBeNull();
|
|
242
|
+
expect(r.irr!).toBeCloseTo(0.097, 2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('payback period is period 3 (cumulative turns positive at t=3)', () => {
|
|
246
|
+
const r = computeNPV(cashFlows, 0.10);
|
|
247
|
+
expect(r.paybackPeriod).toBe(3);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('project with all positive flows has no IRR in (0,2) range', () => {
|
|
251
|
+
const r = computeNPV([{ period: 0, amount: 100 }, { period: 1, amount: 100 }], 0.05);
|
|
252
|
+
// NPV is always positive for all-positive flows; IRR is null (no zero crossing)
|
|
253
|
+
expect(r.irr).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('throws for empty cashFlows', () => {
|
|
257
|
+
expect(() => computeNPV([], 0.05)).toThrow();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ─── VaR ─────────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
describe('computeVaR', () => {
|
|
264
|
+
// Simulate 1000 loss observations from an exponential-like distribution
|
|
265
|
+
const losses = Array.from({ length: 1000 }, (_, i) =>
|
|
266
|
+
-500 + i * 1.5 + Math.sin(i * 0.13) * 50,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
it('var99 ≥ var95 (higher confidence = larger loss threshold)', () => {
|
|
270
|
+
const r = computeVaR(losses);
|
|
271
|
+
expect(r.var99).toBeGreaterThanOrEqual(r.var95);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('cvar95 ≥ var95 (expected shortfall ≥ VaR)', () => {
|
|
275
|
+
const r = computeVaR(losses);
|
|
276
|
+
expect(r.cvar95).toBeGreaterThanOrEqual(r.var95);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('cvar99 ≥ var99', () => {
|
|
280
|
+
const r = computeVaR(losses);
|
|
281
|
+
expect(r.cvar99).toBeGreaterThanOrEqual(r.var99);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('maxLoss is the largest observed loss', () => {
|
|
285
|
+
const r = computeVaR(losses);
|
|
286
|
+
expect(r.maxLoss).toBe(Math.max(...losses));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('sampleSize matches input length', () => {
|
|
290
|
+
const r = computeVaR(losses);
|
|
291
|
+
expect(r.sampleSize).toBe(1000);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('all-negative losses: var95 < 0 (gains at 95th percentile)', () => {
|
|
295
|
+
const gains = Array.from({ length: 100 }, (_, i) => -i - 1);
|
|
296
|
+
const r = computeVaR(gains);
|
|
297
|
+
expect(r.var95).toBeLessThan(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('throws for fewer than 2 observations', () => {
|
|
301
|
+
expect(() => computeVaR([42])).toThrow();
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ─── Gompertz-Makeham ─────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('gompertzMakehamQx', () => {
|
|
308
|
+
// Typical human mortality: A=0.0007, B=0.00005, c=10^(0.04)
|
|
309
|
+
const params = { A: 0.0007, B: 0.00005, c: Math.pow(10, 0.04) };
|
|
310
|
+
|
|
311
|
+
it('returns maxAge+1 values', () => {
|
|
312
|
+
expect(gompertzMakehamQx(params, 99).length).toBe(100);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('mortality increases with age (Gompertz property)', () => {
|
|
316
|
+
const qx = gompertzMakehamQx(params, 99);
|
|
317
|
+
// Should be increasing in the old-age range
|
|
318
|
+
expect(qx[80]).toBeGreaterThan(qx[40]);
|
|
319
|
+
expect(qx[60]).toBeGreaterThan(qx[30]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('all qx values in [0,1]', () => {
|
|
323
|
+
const qx = gompertzMakehamQx(params, 99);
|
|
324
|
+
for (const q of qx) {
|
|
325
|
+
expect(q).toBeGreaterThanOrEqual(0);
|
|
326
|
+
expect(q).toBeLessThanOrEqual(1);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('produces a valid life table when passed to buildLifeTable', () => {
|
|
331
|
+
const qx = gompertzMakehamQx(params, 99);
|
|
332
|
+
const table = buildLifeTable('gompertz-test', qx, 0.05);
|
|
333
|
+
expect(table.rows[0].lx).toBe(100_000);
|
|
334
|
+
expect(table.rows[0].ex).toBeGreaterThan(50);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('throws for c ≤ 1 (non-aging mortality)', () => {
|
|
338
|
+
expect(() => gompertzMakehamQx({ A: 0.001, B: 0.001, c: 0.99 }, 50)).toThrow();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('throws for B ≤ 0', () => {
|
|
342
|
+
expect(() => gompertzMakehamQx({ A: 0.001, B: 0, c: 1.1 }, 50)).toThrow();
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
describe('buildActuarialReceipt', () => {
|
|
349
|
+
const result = computeActuarialValues(
|
|
350
|
+
{ type: 'whole_life', issueAge: 40, benefitAmount: 100_000 },
|
|
351
|
+
ILT,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
it('produces receipt with plugin=insurance and CAEL event', () => {
|
|
355
|
+
const receipt = buildActuarialReceipt(result);
|
|
356
|
+
expect(receipt.plugin).toBe('insurance');
|
|
357
|
+
expect(receipt.cael.event).toBe('insurance.actuarial');
|
|
358
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('accepted=true for valid actuarial result', () => {
|
|
362
|
+
const receipt = buildActuarialReceipt(result);
|
|
363
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
364
|
+
expect(receipt.acceptance.violations).toHaveLength(0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('resultSummary fields are present and positive', () => {
|
|
368
|
+
const receipt = buildActuarialReceipt(result);
|
|
369
|
+
expect(receipt.resultSummary.nsp).toBeGreaterThan(0);
|
|
370
|
+
expect(receipt.resultSummary.annualPremium).toBeGreaterThan(0);
|
|
371
|
+
expect(receipt.resultSummary.lifeExpectancy).toBeGreaterThan(0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('uses provided runId', () => {
|
|
375
|
+
const receipt = buildActuarialReceipt(result, { runId: 'policy-run-42' });
|
|
376
|
+
expect(receipt.runId).toBe('policy-run-42');
|
|
377
|
+
});
|
|
378
|
+
});
|