@actuarial-ts/compliance 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/README.md +2 -2
- package/dist/bundle.d.ts +1 -1
- package/dist/bundle.d.ts.map +1 -1
- package/dist/bundle.js +9 -1
- package/dist/bundle.js.map +1 -1
- package/dist/disclosure.d.ts.map +1 -1
- package/dist/disclosure.js +51 -10
- package/dist/disclosure.js.map +1 -1
- package/package.json +5 -3
- package/src/ave.ts +132 -0
- package/src/bundle.ts +341 -0
- package/src/disclosure.ts +423 -0
- package/src/index.ts +6 -0
- package/src/ledger.ts +140 -0
- package/src/metadata.ts +189 -0
- package/src/modelCards.ts +531 -0
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estimate metadata: the typed identification of what an unpaid-claim
|
|
3
|
+
* estimate IS — its intended purpose and users, the intended measure, the
|
|
4
|
+
* gross/net and LAE basis, and the accounting/valuation/review dates
|
|
5
|
+
* (ASOP 43 terminology; the disclosure generator renders these under
|
|
6
|
+
* ASOP 41).
|
|
7
|
+
*
|
|
8
|
+
* Ground truth:
|
|
9
|
+
* - `validateMetadata` VALIDATES, it does not construct: metadata is often
|
|
10
|
+
* built up field-by-field over the course of an analysis, so callers keep
|
|
11
|
+
* plain objects and ask for the problem list whenever they need to know
|
|
12
|
+
* whether the metadata is disclosure-ready. Empty array = valid.
|
|
13
|
+
* - Validation is defensive at runtime (built-up metadata may have been cast
|
|
14
|
+
* from a partial), so required-field presence and enum membership are
|
|
15
|
+
* checked even though the types already say so.
|
|
16
|
+
* - Dates are caller-supplied ISO strings (yyyy-mm-dd), checked for real
|
|
17
|
+
* calendar validity (leap years included); the module never reads a clock.
|
|
18
|
+
* - `percentile` is a fraction strictly between 0 and 1 (e.g. 0.75 for the
|
|
19
|
+
* 75th percentile) and is only meaningful — and only allowed — when
|
|
20
|
+
* intendedMeasure.kind === "specified-percentile".
|
|
21
|
+
*
|
|
22
|
+
* These utilities are designed to support the actuary's compliance with
|
|
23
|
+
* ASOP Nos. 41 and 43; responsibility for compliance remains with the
|
|
24
|
+
* credentialed actuary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const INTENDED_MEASURE_KINDS = [
|
|
28
|
+
"central-estimate",
|
|
29
|
+
"high-estimate",
|
|
30
|
+
"low-estimate",
|
|
31
|
+
"specified-percentile",
|
|
32
|
+
"range",
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
export type IntendedMeasureKind = (typeof INTENDED_MEASURE_KINDS)[number];
|
|
36
|
+
|
|
37
|
+
export interface IntendedMeasure {
|
|
38
|
+
kind: IntendedMeasureKind;
|
|
39
|
+
/** Fraction in (0, 1), e.g. 0.75; required iff kind === "specified-percentile". */
|
|
40
|
+
percentile?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const GROSS_NET_BASES = [
|
|
44
|
+
"gross",
|
|
45
|
+
"net-of-reinsurance",
|
|
46
|
+
"net-of-salvage-subro",
|
|
47
|
+
"net-all",
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
export type GrossNetBasis = (typeof GROSS_NET_BASES)[number];
|
|
51
|
+
|
|
52
|
+
export const LAE_TREATMENTS = [
|
|
53
|
+
"excluding-lae",
|
|
54
|
+
"including-all-lae",
|
|
55
|
+
"dcc-only",
|
|
56
|
+
"aao-only",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
export type LaeTreatment = (typeof LAE_TREATMENTS)[number];
|
|
60
|
+
|
|
61
|
+
export interface EstimateBasis {
|
|
62
|
+
grossNet: GrossNetBasis;
|
|
63
|
+
laeTreatment: LaeTreatment;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface EstimateMetadata {
|
|
67
|
+
/** Why the estimate exists (e.g. "unpaid claim estimate for the 2025 annual statement"). */
|
|
68
|
+
intendedPurpose: string;
|
|
69
|
+
/** Who may rely on the estimate. */
|
|
70
|
+
intendedUsers?: string[];
|
|
71
|
+
intendedMeasure: IntendedMeasure;
|
|
72
|
+
basis: EstimateBasis;
|
|
73
|
+
/** ISO date (yyyy-mm-dd) used to separate paid from unpaid (ASOP 43 accounting date). */
|
|
74
|
+
accountingDate: string;
|
|
75
|
+
/** ISO date through which transactions are reflected in the data (ASOP 43 valuation date). */
|
|
76
|
+
valuationDate: string;
|
|
77
|
+
/** ISO cutoff for information reflected in the analysis (ASOP 41 review date), when later than the valuation date. */
|
|
78
|
+
reviewDate?: string;
|
|
79
|
+
scopeNotes?: string;
|
|
80
|
+
/** ISO 4217-style currency label (e.g. "USD"); informational, not validated against a table. */
|
|
81
|
+
currency?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
85
|
+
|
|
86
|
+
/** True for a well-formed yyyy-mm-dd string naming a real calendar date. */
|
|
87
|
+
function isIsoDate(value: string): boolean {
|
|
88
|
+
const match = ISO_DATE.exec(value);
|
|
89
|
+
if (!match) return false;
|
|
90
|
+
const year = Number(match[1]);
|
|
91
|
+
const month = Number(match[2]);
|
|
92
|
+
const day = Number(match[3]);
|
|
93
|
+
if (month < 1 || month > 12) return false;
|
|
94
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
95
|
+
const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!;
|
|
96
|
+
return day >= 1 && day <= daysInMonth;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
100
|
+
return typeof value === "string" && value.trim() !== "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkDate(problems: string[], name: string, value: unknown, required: boolean): void {
|
|
104
|
+
if (value === undefined) {
|
|
105
|
+
if (required) problems.push(`${name} is required (ISO yyyy-mm-dd)`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (typeof value !== "string" || !isIsoDate(value)) {
|
|
109
|
+
problems.push(`${name} must be a valid ISO date (yyyy-mm-dd); got ${JSON.stringify(value)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the list of problems keeping `metadata` from being disclosure-ready;
|
|
115
|
+
* an empty array means valid. Never throws — validation, not construction.
|
|
116
|
+
*/
|
|
117
|
+
export function validateMetadata(metadata: EstimateMetadata): string[] {
|
|
118
|
+
const problems: string[] = [];
|
|
119
|
+
|
|
120
|
+
if (!isNonEmptyString(metadata.intendedPurpose)) {
|
|
121
|
+
problems.push("intendedPurpose is required and must be a non-empty string");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (metadata.intendedUsers !== undefined) {
|
|
125
|
+
if (!Array.isArray(metadata.intendedUsers)) {
|
|
126
|
+
problems.push("intendedUsers, when provided, must be an array of non-empty strings");
|
|
127
|
+
} else {
|
|
128
|
+
metadata.intendedUsers.forEach((user, index) => {
|
|
129
|
+
if (!isNonEmptyString(user)) {
|
|
130
|
+
problems.push(`intendedUsers[${index}] must be a non-empty string`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const measure = metadata.intendedMeasure as IntendedMeasure | undefined;
|
|
137
|
+
if (measure === undefined) {
|
|
138
|
+
problems.push("intendedMeasure is required");
|
|
139
|
+
} else {
|
|
140
|
+
if (!(INTENDED_MEASURE_KINDS as readonly string[]).includes(measure.kind)) {
|
|
141
|
+
problems.push(
|
|
142
|
+
`intendedMeasure.kind must be one of ${INTENDED_MEASURE_KINDS.join(", ")}; got ${JSON.stringify(measure.kind)}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (measure.kind === "specified-percentile") {
|
|
146
|
+
const p = measure.percentile;
|
|
147
|
+
if (p === undefined) {
|
|
148
|
+
problems.push('intendedMeasure.percentile is required when kind is "specified-percentile"');
|
|
149
|
+
} else if (typeof p !== "number" || !Number.isFinite(p) || p <= 0 || p >= 1) {
|
|
150
|
+
problems.push(
|
|
151
|
+
`intendedMeasure.percentile must be a fraction strictly between 0 and 1 (e.g. 0.75); got ${JSON.stringify(p)}`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
} else if (measure.percentile !== undefined) {
|
|
155
|
+
problems.push(
|
|
156
|
+
`intendedMeasure.percentile is only meaningful when kind is "specified-percentile"; got kind ${JSON.stringify(measure.kind)}`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const basis = metadata.basis as EstimateBasis | undefined;
|
|
162
|
+
if (basis === undefined) {
|
|
163
|
+
problems.push("basis is required");
|
|
164
|
+
} else {
|
|
165
|
+
if (!(GROSS_NET_BASES as readonly string[]).includes(basis.grossNet)) {
|
|
166
|
+
problems.push(
|
|
167
|
+
`basis.grossNet must be one of ${GROSS_NET_BASES.join(", ")}; got ${JSON.stringify(basis.grossNet)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (!(LAE_TREATMENTS as readonly string[]).includes(basis.laeTreatment)) {
|
|
171
|
+
problems.push(
|
|
172
|
+
`basis.laeTreatment must be one of ${LAE_TREATMENTS.join(", ")}; got ${JSON.stringify(basis.laeTreatment)}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
checkDate(problems, "accountingDate", metadata.accountingDate, true);
|
|
178
|
+
checkDate(problems, "valuationDate", metadata.valuationDate, true);
|
|
179
|
+
checkDate(problems, "reviewDate", metadata.reviewDate, false);
|
|
180
|
+
|
|
181
|
+
if (metadata.scopeNotes !== undefined && !isNonEmptyString(metadata.scopeNotes)) {
|
|
182
|
+
problems.push("scopeNotes, when provided, must be a non-empty string");
|
|
183
|
+
}
|
|
184
|
+
if (metadata.currency !== undefined && !isNonEmptyString(metadata.currency)) {
|
|
185
|
+
problems.push("currency, when provided, must be a non-empty string");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return problems;
|
|
189
|
+
}
|