@actuarial-ts/data 0.6.0 → 0.7.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 +3 -3
- package/dist/compactDiagnosticJson.d.ts +24 -0
- package/dist/compactDiagnosticJson.d.ts.map +1 -0
- package/dist/compactDiagnosticJson.js +200 -0
- package/dist/compactDiagnosticJson.js.map +1 -0
- package/dist/diagnosticInput.d.ts +55 -4
- package/dist/diagnosticInput.d.ts.map +1 -1
- package/dist/diagnosticInput.js +499 -35
- package/dist/diagnosticInput.js.map +1 -1
- package/dist/diagnosticPreparedReview.d.ts +72 -6
- package/dist/diagnosticPreparedReview.d.ts.map +1 -1
- package/dist/diagnosticPreparedReview.js +749 -38
- package/dist/diagnosticPreparedReview.js.map +1 -1
- package/dist/exposure.d.ts.map +1 -1
- package/dist/exposure.js +25 -9
- package/dist/exposure.js.map +1 -1
- package/dist/lossRun.d.ts.map +1 -1
- package/dist/lossRun.js +31 -8
- package/dist/lossRun.js.map +1 -1
- package/dist/review.d.ts +1 -2
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +33 -4
- package/dist/review.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -3
- package/src/compactDiagnosticJson.ts +216 -0
- package/src/diagnosticInput.ts +746 -51
- package/src/diagnosticPreparedReview.ts +1189 -37
- package/src/exposure.ts +61 -16
- package/src/lossRun.ts +55 -13
- package/src/review.ts +177 -40
- package/src/version.ts +1 -1
package/src/exposure.ts
CHANGED
|
@@ -11,9 +11,12 @@ export const exposureRecordSchema = z
|
|
|
11
11
|
exposureUnits: z.number().finite().nullable(),
|
|
12
12
|
})
|
|
13
13
|
.strict()
|
|
14
|
-
.refine(
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
.refine(
|
|
15
|
+
(record) => record.earnedPremium !== null || record.exposureUnits !== null,
|
|
16
|
+
{
|
|
17
|
+
message: "earnedPremium and exposureUnits cannot both be null",
|
|
18
|
+
},
|
|
19
|
+
);
|
|
17
20
|
|
|
18
21
|
export interface ExposureRowError {
|
|
19
22
|
/** 1-based physical file line where the row starts. */
|
|
@@ -45,7 +48,9 @@ function optionalAmount(
|
|
|
45
48
|
}
|
|
46
49
|
const parsed = Number(value);
|
|
47
50
|
if (!Number.isFinite(parsed)) {
|
|
48
|
-
errors.push(
|
|
51
|
+
errors.push(
|
|
52
|
+
`${name} must be blank or a finite decimal number (got "${raw}")`,
|
|
53
|
+
);
|
|
49
54
|
return null;
|
|
50
55
|
}
|
|
51
56
|
return parsed;
|
|
@@ -63,13 +68,29 @@ function optionalAmount(
|
|
|
63
68
|
export function parseExposureCsv(text: string): ExposureParseResult {
|
|
64
69
|
const { rows: grid, rowLines, warnings } = parseCsv(text);
|
|
65
70
|
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
71
|
+
const duplicateHeaders = [
|
|
72
|
+
...new Set(
|
|
73
|
+
headers.filter(
|
|
74
|
+
(header, index) => header !== "" && headers.indexOf(header) !== index,
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
].sort();
|
|
78
|
+
if (duplicateHeaders.length > 0) {
|
|
79
|
+
throw new ReservingError(
|
|
80
|
+
"SHAPE",
|
|
81
|
+
`Duplicate normalized column(s): ${duplicateHeaders.join(", ")}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
66
84
|
if (!headers.includes("origin")) {
|
|
67
85
|
throw new ReservingError(
|
|
68
86
|
"SHAPE",
|
|
69
87
|
`Missing required column: origin. Found: ${headers.filter(Boolean).join(", ") || "(none)"}`,
|
|
70
88
|
);
|
|
71
89
|
}
|
|
72
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
!headers.includes("earned_premium") &&
|
|
92
|
+
!headers.includes("exposure_units")
|
|
93
|
+
) {
|
|
73
94
|
throw new ReservingError(
|
|
74
95
|
"SHAPE",
|
|
75
96
|
"Exposure CSV must include earned_premium, exposure_units, or both",
|
|
@@ -77,12 +98,13 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
77
98
|
}
|
|
78
99
|
|
|
79
100
|
const columnIndex = new Map<string, number>();
|
|
80
|
-
headers.forEach((header, index) =>
|
|
81
|
-
if (!columnIndex.has(header)) columnIndex.set(header, index);
|
|
82
|
-
});
|
|
101
|
+
headers.forEach((header, index) => columnIndex.set(header, index));
|
|
83
102
|
const errors: ExposureRowError[] = warnings.map((warning) => {
|
|
84
103
|
const line = /line (\d+)/.exec(warning)?.[1];
|
|
85
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
row: line === undefined ? 1 : Number(line),
|
|
106
|
+
message: `CSV structure: ${warning}`,
|
|
107
|
+
};
|
|
86
108
|
});
|
|
87
109
|
const parsedRows: { exposure: ExposureRecord; row: number }[] = [];
|
|
88
110
|
|
|
@@ -96,16 +118,32 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
96
118
|
const rowErrors: string[] = [];
|
|
97
119
|
const origin = cell("origin");
|
|
98
120
|
if (origin === "") rowErrors.push("origin is required");
|
|
99
|
-
const earnedPremium = optionalAmount(
|
|
100
|
-
|
|
101
|
-
|
|
121
|
+
const earnedPremium = optionalAmount(
|
|
122
|
+
"earned_premium",
|
|
123
|
+
cell("earned_premium"),
|
|
124
|
+
rowErrors,
|
|
125
|
+
);
|
|
126
|
+
const exposureUnits = optionalAmount(
|
|
127
|
+
"exposure_units",
|
|
128
|
+
cell("exposure_units"),
|
|
129
|
+
rowErrors,
|
|
130
|
+
);
|
|
131
|
+
if (
|
|
132
|
+
earnedPremium === null &&
|
|
133
|
+
exposureUnits === null &&
|
|
134
|
+
rowErrors.length === 0
|
|
135
|
+
) {
|
|
102
136
|
rowErrors.push("earned_premium and exposure_units cannot both be blank");
|
|
103
137
|
}
|
|
104
138
|
if (rowErrors.length > 0) {
|
|
105
139
|
for (const message of rowErrors) errors.push({ row: rowNumber, message });
|
|
106
140
|
continue;
|
|
107
141
|
}
|
|
108
|
-
const validated = exposureRecordSchema.safeParse({
|
|
142
|
+
const validated = exposureRecordSchema.safeParse({
|
|
143
|
+
origin,
|
|
144
|
+
earnedPremium,
|
|
145
|
+
exposureUnits,
|
|
146
|
+
});
|
|
109
147
|
if (!validated.success) {
|
|
110
148
|
for (const issue of validated.error.issues) {
|
|
111
149
|
errors.push({ row: rowNumber, message: issue.message });
|
|
@@ -115,17 +153,24 @@ export function parseExposureCsv(text: string): ExposureParseResult {
|
|
|
115
153
|
parsedRows.push({ exposure: validated.data, row: rowNumber });
|
|
116
154
|
}
|
|
117
155
|
|
|
118
|
-
const rowsByOrigin = new Map<
|
|
156
|
+
const rowsByOrigin = new Map<
|
|
157
|
+
string,
|
|
158
|
+
{ exposure: ExposureRecord; row: number }[]
|
|
159
|
+
>();
|
|
119
160
|
for (const parsed of parsedRows) {
|
|
120
161
|
const sameOrigin = rowsByOrigin.get(parsed.exposure.origin);
|
|
121
|
-
if (sameOrigin === undefined)
|
|
162
|
+
if (sameOrigin === undefined)
|
|
163
|
+
rowsByOrigin.set(parsed.exposure.origin, [parsed]);
|
|
122
164
|
else sameOrigin.push(parsed);
|
|
123
165
|
}
|
|
124
166
|
const exposures: ExposureRecord[] = [];
|
|
125
167
|
for (const [origin, sameOrigin] of rowsByOrigin) {
|
|
126
168
|
if (sameOrigin.length > 1) {
|
|
127
169
|
for (const duplicate of sameOrigin) {
|
|
128
|
-
errors.push({
|
|
170
|
+
errors.push({
|
|
171
|
+
row: duplicate.row,
|
|
172
|
+
message: `duplicate origin "${origin}"`,
|
|
173
|
+
});
|
|
129
174
|
}
|
|
130
175
|
} else {
|
|
131
176
|
exposures.push(sameOrigin[0]!.exposure);
|
package/src/lossRun.ts
CHANGED
|
@@ -63,7 +63,20 @@ function isValidIsoDate(value: string): boolean {
|
|
|
63
63
|
// preserve leapness for every year except 0000, so this is a correctness-of-
|
|
64
64
|
// form consolidation rather than a live-bug fix.
|
|
65
65
|
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
|
|
66
|
-
const daysInMonth = [
|
|
66
|
+
const daysInMonth = [
|
|
67
|
+
31,
|
|
68
|
+
leap ? 29 : 28,
|
|
69
|
+
31,
|
|
70
|
+
30,
|
|
71
|
+
31,
|
|
72
|
+
30,
|
|
73
|
+
31,
|
|
74
|
+
31,
|
|
75
|
+
30,
|
|
76
|
+
31,
|
|
77
|
+
30,
|
|
78
|
+
31,
|
|
79
|
+
][m - 1]!;
|
|
67
80
|
return d <= daysInMonth;
|
|
68
81
|
}
|
|
69
82
|
|
|
@@ -71,6 +84,19 @@ function isValidIsoDate(value: string): boolean {
|
|
|
71
84
|
export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
72
85
|
const { rows: grid, rowLines, warnings: csvWarnings } = parseCsv(text);
|
|
73
86
|
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
87
|
+
const duplicateHeaders = [
|
|
88
|
+
...new Set(
|
|
89
|
+
headers.filter(
|
|
90
|
+
(header, index) => header !== "" && headers.indexOf(header) !== index,
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
].sort();
|
|
94
|
+
if (duplicateHeaders.length > 0) {
|
|
95
|
+
throw new ReservingError(
|
|
96
|
+
"SHAPE",
|
|
97
|
+
`Duplicate normalized column(s): ${duplicateHeaders.join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
74
100
|
const missing = REQUIRED_COLUMNS.filter((c) => !headers.includes(c));
|
|
75
101
|
if (missing.length > 0) {
|
|
76
102
|
throw new ReservingError(
|
|
@@ -81,9 +107,7 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
81
107
|
);
|
|
82
108
|
}
|
|
83
109
|
const columnIndex = new Map<string, number>();
|
|
84
|
-
headers.forEach((h, idx) =>
|
|
85
|
-
if (!columnIndex.has(h)) columnIndex.set(h, idx);
|
|
86
|
-
});
|
|
110
|
+
headers.forEach((h, idx) => columnIndex.set(h, idx));
|
|
87
111
|
|
|
88
112
|
const claims: ClaimSnapshot[] = [];
|
|
89
113
|
const errors: LossRunRowError[] = [];
|
|
@@ -91,7 +115,10 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
91
115
|
// Structural CSV problems surface through the same channel as row errors:
|
|
92
116
|
// partial loading is intended behavior here, silent partial loading is not.
|
|
93
117
|
const lineMatch = warning.match(/line (\d+)/);
|
|
94
|
-
errors.push({
|
|
118
|
+
errors.push({
|
|
119
|
+
row: lineMatch ? Number(lineMatch[1]) : 1,
|
|
120
|
+
message: `CSV structure: ${warning}`,
|
|
121
|
+
});
|
|
95
122
|
}
|
|
96
123
|
|
|
97
124
|
for (let r = 1; r < grid.length; r++) {
|
|
@@ -101,7 +128,8 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
101
128
|
// lines. One errors array, one numbering scheme.
|
|
102
129
|
const rowNumber = rowLines[r]!;
|
|
103
130
|
const cells = grid[r]!;
|
|
104
|
-
const cell = (name: string): string =>
|
|
131
|
+
const cell = (name: string): string =>
|
|
132
|
+
(cells[columnIndex.get(name)!] ?? "").trim();
|
|
105
133
|
const rowErrors: string[] = [];
|
|
106
134
|
|
|
107
135
|
const claimId = cell("claim_id");
|
|
@@ -110,7 +138,9 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
110
138
|
const date = (name: string): string | null => {
|
|
111
139
|
const value = cell(name);
|
|
112
140
|
if (!isValidIsoDate(value)) {
|
|
113
|
-
rowErrors.push(
|
|
141
|
+
rowErrors.push(
|
|
142
|
+
`${name} must be a real calendar date in yyyy-mm-dd form (got "${value}")`,
|
|
143
|
+
);
|
|
114
144
|
return null;
|
|
115
145
|
}
|
|
116
146
|
return value;
|
|
@@ -134,9 +164,13 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
134
164
|
);
|
|
135
165
|
return null;
|
|
136
166
|
}
|
|
137
|
-
const n = /^-?\d+(\.\d+)?$/.test(value.trim())
|
|
167
|
+
const n = /^-?\d+(\.\d+)?$/.test(value.trim())
|
|
168
|
+
? Number(value.trim())
|
|
169
|
+
: NaN;
|
|
138
170
|
if (!Number.isFinite(n)) {
|
|
139
|
-
rowErrors.push(
|
|
171
|
+
rowErrors.push(
|
|
172
|
+
`${name} must be a finite decimal number (got "${value}")`,
|
|
173
|
+
);
|
|
140
174
|
return null;
|
|
141
175
|
}
|
|
142
176
|
return n;
|
|
@@ -148,13 +182,21 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
148
182
|
const status: "open" | "closed" | null =
|
|
149
183
|
statusRaw === "open" || statusRaw === "closed" ? statusRaw : null;
|
|
150
184
|
if (status === null) {
|
|
151
|
-
rowErrors.push(
|
|
185
|
+
rowErrors.push(
|
|
186
|
+
`status must be "open" or "closed" (got "${cell("status")}")`,
|
|
187
|
+
);
|
|
152
188
|
}
|
|
153
189
|
|
|
154
190
|
// Cross-field checks need all three dates to be individually valid.
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
191
|
+
if (
|
|
192
|
+
accidentDate !== null &&
|
|
193
|
+
reportDate !== null &&
|
|
194
|
+
evaluationDate !== null
|
|
195
|
+
) {
|
|
196
|
+
if (reportDate < accidentDate)
|
|
197
|
+
rowErrors.push("report_date precedes accident_date");
|
|
198
|
+
if (evaluationDate < reportDate)
|
|
199
|
+
rowErrors.push("evaluation_date precedes report_date");
|
|
158
200
|
}
|
|
159
201
|
|
|
160
202
|
if (rowErrors.length > 0) {
|
package/src/review.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
isRealIsoDate,
|
|
3
|
+
type ClaimSnapshot,
|
|
4
|
+
type Triangle,
|
|
5
|
+
} from "@actuarial-ts/core";
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* ASOP No. 23 (Data Quality)-oriented data review.
|
|
@@ -39,7 +43,6 @@ export interface DataFindingContext {
|
|
|
39
43
|
recordId?: string;
|
|
40
44
|
claimId?: string;
|
|
41
45
|
exposureKey?: string;
|
|
42
|
-
ageMonths?: number;
|
|
43
46
|
group?: string;
|
|
44
47
|
sourceFile?: string;
|
|
45
48
|
sourceRow?: number;
|
|
@@ -65,7 +68,12 @@ export interface DataCheck {
|
|
|
65
68
|
|
|
66
69
|
export interface DataReviewReport {
|
|
67
70
|
checks: DataCheck[];
|
|
68
|
-
summary: {
|
|
71
|
+
summary: {
|
|
72
|
+
pass: number;
|
|
73
|
+
warning: number;
|
|
74
|
+
fail: number;
|
|
75
|
+
notEvaluated: number;
|
|
76
|
+
};
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export interface ReviewClaimDataOptions {
|
|
@@ -78,7 +86,10 @@ const MAX_DETAILS = 20;
|
|
|
78
86
|
|
|
79
87
|
function capDetails(items: string[]): string[] {
|
|
80
88
|
if (items.length <= MAX_DETAILS) return items;
|
|
81
|
-
return [
|
|
89
|
+
return [
|
|
90
|
+
...items.slice(0, MAX_DETAILS),
|
|
91
|
+
`+${items.length - MAX_DETAILS} more`,
|
|
92
|
+
];
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
function makeCheck(
|
|
@@ -96,10 +107,20 @@ function makeCheck(
|
|
|
96
107
|
};
|
|
97
108
|
}
|
|
98
109
|
|
|
99
|
-
function notEvaluated(
|
|
110
|
+
function notEvaluated(
|
|
111
|
+
id: string,
|
|
112
|
+
description: string,
|
|
113
|
+
reason: string,
|
|
114
|
+
): DataCheck {
|
|
100
115
|
// A check that could not run is REPORTED as such - counting it as "pass"
|
|
101
116
|
// would overstate the review in the very disclosure this feeds.
|
|
102
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
id,
|
|
119
|
+
description,
|
|
120
|
+
status: "not-evaluated",
|
|
121
|
+
details: [`not evaluated: ${reason}`],
|
|
122
|
+
findings: [],
|
|
123
|
+
};
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
function summarize(checks: DataCheck[]): DataReviewReport {
|
|
@@ -118,9 +139,13 @@ export function createStructuredDataCheck(
|
|
|
118
139
|
statusWhenFound: "warning" | "fail",
|
|
119
140
|
findings: readonly DataFinding[],
|
|
120
141
|
): DataCheck {
|
|
121
|
-
const capped =
|
|
142
|
+
const capped =
|
|
143
|
+
findings.length <= MAX_DETAILS
|
|
144
|
+
? [...findings]
|
|
145
|
+
: [...findings.slice(0, MAX_DETAILS)];
|
|
122
146
|
const details = capped.map((finding) => finding.message);
|
|
123
|
-
if (findings.length > MAX_DETAILS)
|
|
147
|
+
if (findings.length > MAX_DETAILS)
|
|
148
|
+
details.push(`+${findings.length - MAX_DETAILS} more`);
|
|
124
149
|
return {
|
|
125
150
|
id,
|
|
126
151
|
description,
|
|
@@ -145,13 +170,19 @@ export function createNotEvaluatedDataCheck(
|
|
|
145
170
|
}
|
|
146
171
|
|
|
147
172
|
const CLAIM_DESCRIPTIONS = {
|
|
148
|
-
"non-finite-value":
|
|
173
|
+
"non-finite-value":
|
|
174
|
+
"Every money field is a finite number (no NaN or Infinity)",
|
|
149
175
|
"negative-paid": "Cumulative paid amounts are non-negative",
|
|
150
|
-
"negative-case":
|
|
176
|
+
"negative-case":
|
|
177
|
+
"Case reserves are non-negative (negative case is legitimate but rare)",
|
|
151
178
|
"paid-decreasing":
|
|
152
179
|
"Cumulative paid never decreases across a claim's snapshots ordered by evaluation date",
|
|
153
|
-
"date-order":
|
|
154
|
-
|
|
180
|
+
"date-order":
|
|
181
|
+
"accident_date <= report_date <= evaluation_date on every snapshot",
|
|
182
|
+
"duplicate-snapshot":
|
|
183
|
+
"No claim has two snapshots at the same evaluation date",
|
|
184
|
+
"claim-identity":
|
|
185
|
+
"Every snapshot for one claim has the same accident and report identity",
|
|
155
186
|
"future-dated": "No claim date exceeds the as-of date",
|
|
156
187
|
"closed-with-case": "Closed claims carry no outstanding case reserve",
|
|
157
188
|
} as const;
|
|
@@ -175,6 +206,16 @@ export function reviewClaimData(
|
|
|
175
206
|
// pointed auditors at the wrong line; an identifier we cannot compute is
|
|
176
207
|
// one we must not print.
|
|
177
208
|
const where = `claim ${c.claimId} (eval ${c.evaluationDate})`;
|
|
209
|
+
for (const [name, value] of [
|
|
210
|
+
["accident_date", c.accidentDate],
|
|
211
|
+
["report_date", c.reportDate],
|
|
212
|
+
["evaluation_date", c.evaluationDate],
|
|
213
|
+
] as const) {
|
|
214
|
+
if (!isRealIsoDate(value))
|
|
215
|
+
dateOrder.push(
|
|
216
|
+
`${where}: ${name} is not a real yyyy-mm-dd calendar date`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
178
219
|
if (c.paidToDate < 0) {
|
|
179
220
|
negativePaid.push(`${where}: paid_to_date ${c.paidToDate}`);
|
|
180
221
|
}
|
|
@@ -194,17 +235,25 @@ export function reviewClaimData(
|
|
|
194
235
|
if (opts.asOfDate !== undefined) {
|
|
195
236
|
const asOf = opts.asOfDate;
|
|
196
237
|
if (c.accidentDate > asOf) {
|
|
197
|
-
futureDated.push(
|
|
238
|
+
futureDated.push(
|
|
239
|
+
`${where}: accident_date ${c.accidentDate} exceeds as-of ${asOf}`,
|
|
240
|
+
);
|
|
198
241
|
}
|
|
199
242
|
if (c.reportDate > asOf) {
|
|
200
|
-
futureDated.push(
|
|
243
|
+
futureDated.push(
|
|
244
|
+
`${where}: report_date ${c.reportDate} exceeds as-of ${asOf}`,
|
|
245
|
+
);
|
|
201
246
|
}
|
|
202
247
|
if (c.evaluationDate > asOf) {
|
|
203
|
-
futureDated.push(
|
|
248
|
+
futureDated.push(
|
|
249
|
+
`${where}: evaluation_date ${c.evaluationDate} exceeds as-of ${asOf}`,
|
|
250
|
+
);
|
|
204
251
|
}
|
|
205
252
|
}
|
|
206
253
|
if (c.status === "closed" && c.caseReserve > 0) {
|
|
207
|
-
closedWithCase.push(
|
|
254
|
+
closedWithCase.push(
|
|
255
|
+
`${where}: case_reserve ${c.caseReserve} on a closed claim`,
|
|
256
|
+
);
|
|
208
257
|
}
|
|
209
258
|
});
|
|
210
259
|
|
|
@@ -217,12 +266,24 @@ export function reviewClaimData(
|
|
|
217
266
|
}
|
|
218
267
|
const paidDecreasing: string[] = [];
|
|
219
268
|
const duplicates: string[] = [];
|
|
269
|
+
const identityConflicts: string[] = [];
|
|
220
270
|
for (const [claimId, snaps] of byClaim) {
|
|
221
|
-
const
|
|
271
|
+
const identities = new Set(
|
|
272
|
+
snaps.map((snap) => `${snap.accidentDate}|${snap.reportDate}`),
|
|
273
|
+
);
|
|
274
|
+
if (identities.size > 1)
|
|
275
|
+
identityConflicts.push(
|
|
276
|
+
`claim ${claimId}: snapshots contain conflicting accident_date or report_date values`,
|
|
277
|
+
);
|
|
278
|
+
const sorted = [...snaps].sort((a, b) =>
|
|
279
|
+
a.evaluationDate.localeCompare(b.evaluationDate),
|
|
280
|
+
);
|
|
222
281
|
const seenEvals = new Set<string>();
|
|
223
282
|
for (const s of sorted) {
|
|
224
283
|
if (seenEvals.has(s.evaluationDate)) {
|
|
225
|
-
duplicates.push(
|
|
284
|
+
duplicates.push(
|
|
285
|
+
`claim ${claimId}: duplicate snapshot at ${s.evaluationDate}`,
|
|
286
|
+
);
|
|
226
287
|
}
|
|
227
288
|
seenEvals.add(s.evaluationDate);
|
|
228
289
|
}
|
|
@@ -242,32 +303,87 @@ export function reviewClaimData(
|
|
|
242
303
|
const nonFinite: string[] = [];
|
|
243
304
|
for (const c of claims) {
|
|
244
305
|
const where = `claim ${c.claimId}`;
|
|
245
|
-
if (!Number.isFinite(c.paidToDate))
|
|
246
|
-
|
|
306
|
+
if (!Number.isFinite(c.paidToDate))
|
|
307
|
+
nonFinite.push(`${where}: paid_to_date ${String(c.paidToDate)}`);
|
|
308
|
+
if (!Number.isFinite(c.caseReserve))
|
|
309
|
+
nonFinite.push(`${where}: case_reserve ${String(c.caseReserve)}`);
|
|
247
310
|
}
|
|
248
311
|
|
|
249
312
|
const futureCheck =
|
|
250
313
|
opts.asOfDate === undefined
|
|
251
|
-
? notEvaluated(
|
|
252
|
-
|
|
314
|
+
? notEvaluated(
|
|
315
|
+
"future-dated",
|
|
316
|
+
CLAIM_DESCRIPTIONS["future-dated"],
|
|
317
|
+
"no asOfDate provided",
|
|
318
|
+
)
|
|
319
|
+
: makeCheck(
|
|
320
|
+
"future-dated",
|
|
321
|
+
CLAIM_DESCRIPTIONS["future-dated"],
|
|
322
|
+
"fail",
|
|
323
|
+
futureDated,
|
|
324
|
+
);
|
|
253
325
|
|
|
254
326
|
return summarize([
|
|
255
327
|
// First: if the numbers are not numbers, the other verdicts are noise.
|
|
256
|
-
makeCheck(
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
328
|
+
makeCheck(
|
|
329
|
+
"non-finite-value",
|
|
330
|
+
CLAIM_DESCRIPTIONS["non-finite-value"],
|
|
331
|
+
"fail",
|
|
332
|
+
nonFinite,
|
|
333
|
+
),
|
|
334
|
+
makeCheck(
|
|
335
|
+
"negative-paid",
|
|
336
|
+
CLAIM_DESCRIPTIONS["negative-paid"],
|
|
337
|
+
"fail",
|
|
338
|
+
negativePaid,
|
|
339
|
+
),
|
|
340
|
+
makeCheck(
|
|
341
|
+
"negative-case",
|
|
342
|
+
CLAIM_DESCRIPTIONS["negative-case"],
|
|
343
|
+
"warning",
|
|
344
|
+
negativeCase,
|
|
345
|
+
),
|
|
346
|
+
makeCheck(
|
|
347
|
+
"paid-decreasing",
|
|
348
|
+
CLAIM_DESCRIPTIONS["paid-decreasing"],
|
|
349
|
+
"fail",
|
|
350
|
+
paidDecreasing,
|
|
351
|
+
),
|
|
352
|
+
makeCheck(
|
|
353
|
+
"date-order",
|
|
354
|
+
CLAIM_DESCRIPTIONS["date-order"],
|
|
355
|
+
"fail",
|
|
356
|
+
dateOrder,
|
|
357
|
+
),
|
|
358
|
+
makeCheck(
|
|
359
|
+
"duplicate-snapshot",
|
|
360
|
+
CLAIM_DESCRIPTIONS["duplicate-snapshot"],
|
|
361
|
+
"fail",
|
|
362
|
+
duplicates,
|
|
363
|
+
),
|
|
364
|
+
makeCheck(
|
|
365
|
+
"claim-identity",
|
|
366
|
+
CLAIM_DESCRIPTIONS["claim-identity"],
|
|
367
|
+
"fail",
|
|
368
|
+
identityConflicts,
|
|
369
|
+
),
|
|
262
370
|
futureCheck,
|
|
263
|
-
makeCheck(
|
|
371
|
+
makeCheck(
|
|
372
|
+
"closed-with-case",
|
|
373
|
+
CLAIM_DESCRIPTIONS["closed-with-case"],
|
|
374
|
+
"warning",
|
|
375
|
+
closedWithCase,
|
|
376
|
+
),
|
|
264
377
|
]);
|
|
265
378
|
}
|
|
266
379
|
|
|
267
380
|
const TRIANGLE_DESCRIPTIONS = {
|
|
268
|
-
"non-finite-value":
|
|
269
|
-
|
|
270
|
-
"
|
|
381
|
+
"non-finite-value":
|
|
382
|
+
"Every observed cell is a finite number (no NaN or Infinity)",
|
|
383
|
+
"shape-mismatch":
|
|
384
|
+
"Paid and incurred triangles share the same origins and ages",
|
|
385
|
+
"paid-exceeds-incurred":
|
|
386
|
+
"Paid never exceeds incurred in any cell (1e-9 relative tolerance)",
|
|
271
387
|
"negative-incremental-paid":
|
|
272
388
|
"Cumulative paid is non-decreasing along each origin row (salvage/subrogation can legitimately violate this)",
|
|
273
389
|
"negative-incremental-incurred":
|
|
@@ -290,7 +406,9 @@ function nonFiniteTriangleFindings(tri: Triangle): string[] {
|
|
|
290
406
|
const v = row[j];
|
|
291
407
|
if (v === null || v === undefined) continue;
|
|
292
408
|
if (!Number.isFinite(v)) {
|
|
293
|
-
out.push(
|
|
409
|
+
out.push(
|
|
410
|
+
`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: ${String(v)}`,
|
|
411
|
+
);
|
|
294
412
|
}
|
|
295
413
|
}
|
|
296
414
|
}
|
|
@@ -334,7 +452,9 @@ function interiorMissingFindings(tri: Triangle): string[] {
|
|
|
334
452
|
if (first === -1) continue;
|
|
335
453
|
for (let j = first + 1; j < last; j++) {
|
|
336
454
|
if (!observed[j]) {
|
|
337
|
-
out.push(
|
|
455
|
+
out.push(
|
|
456
|
+
`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: interior cell missing`,
|
|
457
|
+
);
|
|
338
458
|
}
|
|
339
459
|
}
|
|
340
460
|
}
|
|
@@ -342,7 +462,10 @@ function interiorMissingFindings(tri: Triangle): string[] {
|
|
|
342
462
|
}
|
|
343
463
|
|
|
344
464
|
/** Reviews a paid/incurred triangle pair for cross-triangle consistency. */
|
|
345
|
-
export function reviewTriangles(
|
|
465
|
+
export function reviewTriangles(
|
|
466
|
+
paid: Triangle,
|
|
467
|
+
incurred: Triangle,
|
|
468
|
+
): DataReviewReport {
|
|
346
469
|
const shapeFindings: string[] = [];
|
|
347
470
|
const sameOrigins =
|
|
348
471
|
paid.origins.length === incurred.origins.length &&
|
|
@@ -364,7 +487,10 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
364
487
|
"non-finite-value",
|
|
365
488
|
TRIANGLE_DESCRIPTIONS["non-finite-value"],
|
|
366
489
|
"fail",
|
|
367
|
-
[
|
|
490
|
+
[
|
|
491
|
+
...nonFiniteTriangleFindings(paid),
|
|
492
|
+
...nonFiniteTriangleFindings(incurred),
|
|
493
|
+
],
|
|
368
494
|
);
|
|
369
495
|
const shapeCheck = makeCheck(
|
|
370
496
|
"shape-mismatch",
|
|
@@ -379,7 +505,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
379
505
|
return summarize([
|
|
380
506
|
nonFiniteCheck,
|
|
381
507
|
shapeCheck,
|
|
382
|
-
notEvaluated(
|
|
508
|
+
notEvaluated(
|
|
509
|
+
"paid-exceeds-incurred",
|
|
510
|
+
TRIANGLE_DESCRIPTIONS["paid-exceeds-incurred"],
|
|
511
|
+
reason,
|
|
512
|
+
),
|
|
383
513
|
notEvaluated(
|
|
384
514
|
"negative-incremental-paid",
|
|
385
515
|
TRIANGLE_DESCRIPTIONS["negative-incremental-paid"],
|
|
@@ -390,7 +520,11 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
390
520
|
TRIANGLE_DESCRIPTIONS["negative-incremental-incurred"],
|
|
391
521
|
reason,
|
|
392
522
|
),
|
|
393
|
-
notEvaluated(
|
|
523
|
+
notEvaluated(
|
|
524
|
+
"interior-missing",
|
|
525
|
+
TRIANGLE_DESCRIPTIONS["interior-missing"],
|
|
526
|
+
reason,
|
|
527
|
+
),
|
|
394
528
|
]);
|
|
395
529
|
}
|
|
396
530
|
|
|
@@ -401,10 +535,13 @@ export function reviewTriangles(paid: Triangle, incurred: Triangle): DataReviewR
|
|
|
401
535
|
for (let j = 0; j < paidRow.length; j++) {
|
|
402
536
|
const p = paidRow[j];
|
|
403
537
|
const inc = incRow[j];
|
|
404
|
-
if (p === null || p === undefined || inc === null || inc === undefined)
|
|
538
|
+
if (p === null || p === undefined || inc === null || inc === undefined)
|
|
539
|
+
continue;
|
|
405
540
|
const tolerance = 1e-9 * Math.max(1, Math.abs(p), Math.abs(inc));
|
|
406
541
|
if (p - inc > tolerance) {
|
|
407
|
-
paidExceeds.push(
|
|
542
|
+
paidExceeds.push(
|
|
543
|
+
`${paid.origins[i]} age ${paid.ages[j]}: paid ${p} > incurred ${inc}`,
|
|
544
|
+
);
|
|
408
545
|
}
|
|
409
546
|
}
|
|
410
547
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const DATA_PACKAGE_VERSION = "0.
|
|
1
|
+
export const DATA_PACKAGE_VERSION = "0.7.0";
|