@actuarial-ts/data 0.3.0 → 0.4.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 +86 -4
- package/dist/annualDevelopment.d.ts +94 -0
- package/dist/annualDevelopment.d.ts.map +1 -0
- package/dist/annualDevelopment.js +188 -0
- package/dist/annualDevelopment.js.map +1 -0
- package/dist/csv.d.ts +11 -1
- package/dist/csv.d.ts.map +1 -1
- package/dist/csv.js +9 -2
- package/dist/csv.js.map +1 -1
- package/dist/diagnosticInput.d.ts +14 -0
- package/dist/diagnosticInput.d.ts.map +1 -0
- package/dist/diagnosticInput.js +51 -0
- package/dist/diagnosticInput.js.map +1 -0
- package/dist/diagnosticReview.d.ts +68 -0
- package/dist/diagnosticReview.d.ts.map +1 -0
- package/dist/diagnosticReview.js +370 -0
- package/dist/diagnosticReview.js.map +1 -0
- package/dist/exposure.d.ts +44 -0
- package/dist/exposure.d.ts.map +1 -0
- package/dist/exposure.js +113 -0
- package/dist/exposure.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lossRun.d.ts +1 -1
- package/dist/lossRun.d.ts.map +1 -1
- package/dist/lossRun.js +9 -4
- package/dist/lossRun.js.map +1 -1
- package/dist/review.d.ts +22 -0
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +25 -3
- package/dist/review.js.map +1 -1
- package/package.json +4 -3
- package/src/annualDevelopment.ts +278 -0
- package/src/csv.ts +17 -2
- package/src/diagnosticInput.ts +79 -0
- package/src/diagnosticReview.ts +547 -0
- package/src/exposure.ts +136 -0
- package/src/index.ts +4 -0
- package/src/lossRun.ts +10 -5
- package/src/review.ts +54 -3
package/src/exposure.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ExposureRecord } from "@actuarial-ts/core";
|
|
2
|
+
import { ReservingError } from "@actuarial-ts/core";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { parseCsv } from "./csv.js";
|
|
5
|
+
|
|
6
|
+
/** Runtime schema for an exposure record after CSV field parsing. */
|
|
7
|
+
export const exposureRecordSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
origin: z.string().trim().min(1),
|
|
10
|
+
earnedPremium: z.number().finite().nullable(),
|
|
11
|
+
exposureUnits: z.number().finite().nullable(),
|
|
12
|
+
})
|
|
13
|
+
.strict()
|
|
14
|
+
.refine((record) => record.earnedPremium !== null || record.exposureUnits !== null, {
|
|
15
|
+
message: "earnedPremium and exposureUnits cannot both be null",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export interface ExposureRowError {
|
|
19
|
+
/** 1-based physical file line where the row starts. */
|
|
20
|
+
row: number;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExposureParseResult {
|
|
25
|
+
exposures: ExposureRecord[];
|
|
26
|
+
errors: ExposureRowError[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeHeader(header: string): string {
|
|
30
|
+
return header.trim().toLowerCase().replace(/\s+/g, "_");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function optionalAmount(
|
|
34
|
+
name: string,
|
|
35
|
+
raw: string,
|
|
36
|
+
errors: string[],
|
|
37
|
+
): number | null {
|
|
38
|
+
const value = raw.trim();
|
|
39
|
+
if (value === "") return null;
|
|
40
|
+
if (/[,()]|\s/.test(value) || !/^-?\d+(\.\d+)?$/.test(value)) {
|
|
41
|
+
errors.push(
|
|
42
|
+
`${name} must be blank or an unformatted finite decimal (got "${raw}")`,
|
|
43
|
+
);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const parsed = Number(value);
|
|
47
|
+
if (!Number.isFinite(parsed)) {
|
|
48
|
+
errors.push(`${name} must be blank or a finite decimal number (got "${raw}")`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parses origin-period exposure data.
|
|
56
|
+
*
|
|
57
|
+
* Required header: `origin`, plus at least one of `earned_premium` or
|
|
58
|
+
* `exposure_units`. Either numeric field may be blank on an individual row,
|
|
59
|
+
* but not both. Extra source fields (for example gross written premium or a
|
|
60
|
+
* source claim count) are intentionally preserved in the caller's source file
|
|
61
|
+
* and ignored here rather than relabeled as an SDK measure.
|
|
62
|
+
*/
|
|
63
|
+
export function parseExposureCsv(text: string): ExposureParseResult {
|
|
64
|
+
const { rows: grid, rowLines, warnings } = parseCsv(text);
|
|
65
|
+
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
66
|
+
if (!headers.includes("origin")) {
|
|
67
|
+
throw new ReservingError(
|
|
68
|
+
"SHAPE",
|
|
69
|
+
`Missing required column: origin. Found: ${headers.filter(Boolean).join(", ") || "(none)"}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!headers.includes("earned_premium") && !headers.includes("exposure_units")) {
|
|
73
|
+
throw new ReservingError(
|
|
74
|
+
"SHAPE",
|
|
75
|
+
"Exposure CSV must include earned_premium, exposure_units, or both",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const columnIndex = new Map<string, number>();
|
|
80
|
+
headers.forEach((header, index) => {
|
|
81
|
+
if (!columnIndex.has(header)) columnIndex.set(header, index);
|
|
82
|
+
});
|
|
83
|
+
const errors: ExposureRowError[] = warnings.map((warning) => {
|
|
84
|
+
const line = /line (\d+)/.exec(warning)?.[1];
|
|
85
|
+
return { row: line === undefined ? 1 : Number(line), message: `CSV structure: ${warning}` };
|
|
86
|
+
});
|
|
87
|
+
const parsedRows: { exposure: ExposureRecord; row: number }[] = [];
|
|
88
|
+
|
|
89
|
+
for (let index = 1; index < grid.length; index++) {
|
|
90
|
+
const row = grid[index]!;
|
|
91
|
+
const rowNumber = rowLines[index]!;
|
|
92
|
+
const cell = (name: string): string => {
|
|
93
|
+
const position = columnIndex.get(name);
|
|
94
|
+
return position === undefined ? "" : (row[position] ?? "").trim();
|
|
95
|
+
};
|
|
96
|
+
const rowErrors: string[] = [];
|
|
97
|
+
const origin = cell("origin");
|
|
98
|
+
if (origin === "") rowErrors.push("origin is required");
|
|
99
|
+
const earnedPremium = optionalAmount("earned_premium", cell("earned_premium"), rowErrors);
|
|
100
|
+
const exposureUnits = optionalAmount("exposure_units", cell("exposure_units"), rowErrors);
|
|
101
|
+
if (earnedPremium === null && exposureUnits === null && rowErrors.length === 0) {
|
|
102
|
+
rowErrors.push("earned_premium and exposure_units cannot both be blank");
|
|
103
|
+
}
|
|
104
|
+
if (rowErrors.length > 0) {
|
|
105
|
+
for (const message of rowErrors) errors.push({ row: rowNumber, message });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const validated = exposureRecordSchema.safeParse({ origin, earnedPremium, exposureUnits });
|
|
109
|
+
if (!validated.success) {
|
|
110
|
+
for (const issue of validated.error.issues) {
|
|
111
|
+
errors.push({ row: rowNumber, message: issue.message });
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
parsedRows.push({ exposure: validated.data, row: rowNumber });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const rowsByOrigin = new Map<string, { exposure: ExposureRecord; row: number }[]>();
|
|
119
|
+
for (const parsed of parsedRows) {
|
|
120
|
+
const sameOrigin = rowsByOrigin.get(parsed.exposure.origin);
|
|
121
|
+
if (sameOrigin === undefined) rowsByOrigin.set(parsed.exposure.origin, [parsed]);
|
|
122
|
+
else sameOrigin.push(parsed);
|
|
123
|
+
}
|
|
124
|
+
const exposures: ExposureRecord[] = [];
|
|
125
|
+
for (const [origin, sameOrigin] of rowsByOrigin) {
|
|
126
|
+
if (sameOrigin.length > 1) {
|
|
127
|
+
for (const duplicate of sameOrigin) {
|
|
128
|
+
errors.push({ row: duplicate.row, message: `duplicate origin "${origin}"` });
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
exposures.push(sameOrigin[0]!.exposure);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
errors.sort((a, b) => a.row - b.row);
|
|
135
|
+
return { exposures, errors };
|
|
136
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export * from "./csv.js";
|
|
2
|
+
export * from "./annualDevelopment.js";
|
|
3
|
+
export * from "./exposure.js";
|
|
4
|
+
export * from "./diagnosticInput.js";
|
|
5
|
+
export * from "./diagnosticReview.js";
|
|
2
6
|
export * from "./lossRun.js";
|
|
3
7
|
export * from "./longFormat.js";
|
|
4
8
|
export * from "./review.js";
|
package/src/lossRun.ts
CHANGED
|
@@ -15,8 +15,9 @@ import { parseCsv } from "./csv.js";
|
|
|
15
15
|
* collected — not thrown — so the caller decides whether to abort or load
|
|
16
16
|
* the clean rows; a row with any error contributes no claim.
|
|
17
17
|
*
|
|
18
|
-
* Row numbers in errors are 1-based
|
|
19
|
-
*
|
|
18
|
+
* Row numbers in errors are the 1-based PHYSICAL line in the file where the
|
|
19
|
+
* row starts (header included; blank lines and newlines inside quoted fields
|
|
20
|
+
* count), so in a file with no blank lines the first data row is row 2.
|
|
20
21
|
*
|
|
21
22
|
* Unlike the workbench importer, negative paid/case amounts are accepted
|
|
22
23
|
* here: the ASOP 23 review layer (reviewClaimData) flags them, keeping the
|
|
@@ -34,7 +35,7 @@ const REQUIRED_COLUMNS = [
|
|
|
34
35
|
] as const;
|
|
35
36
|
|
|
36
37
|
export interface LossRunRowError {
|
|
37
|
-
/** 1-based
|
|
38
|
+
/** 1-based physical file line where the row starts (first data row = 2 when nothing precedes it). */
|
|
38
39
|
row: number;
|
|
39
40
|
message: string;
|
|
40
41
|
}
|
|
@@ -68,7 +69,7 @@ function isValidIsoDate(value: string): boolean {
|
|
|
68
69
|
|
|
69
70
|
/** Parses a loss-run CSV into ClaimSnapshots plus per-row validation errors. */
|
|
70
71
|
export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
71
|
-
const { rows: grid, warnings: csvWarnings } = parseCsv(text);
|
|
72
|
+
const { rows: grid, rowLines, warnings: csvWarnings } = parseCsv(text);
|
|
72
73
|
const headers = (grid[0] ?? []).map(normalizeHeader);
|
|
73
74
|
const missing = REQUIRED_COLUMNS.filter((c) => !headers.includes(c));
|
|
74
75
|
if (missing.length > 0) {
|
|
@@ -94,7 +95,11 @@ export function parseLossRunCsv(text: string): LossRunParseResult {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
for (let r = 1; r < grid.length; r++) {
|
|
97
|
-
|
|
98
|
+
// Physical 1-based line in the file where this row starts — NOT r + 1:
|
|
99
|
+
// blank lines and quoted embedded newlines make grid index and file line
|
|
100
|
+
// diverge, and the structural-warning path above already reports physical
|
|
101
|
+
// lines. One errors array, one numbering scheme.
|
|
102
|
+
const rowNumber = rowLines[r]!;
|
|
98
103
|
const cells = grid[r]!;
|
|
99
104
|
const cell = (name: string): string => (cells[columnIndex.get(name)!] ?? "").trim();
|
|
100
105
|
const rowErrors: string[] = [];
|
package/src/review.ts
CHANGED
|
@@ -24,11 +24,29 @@ import type { ClaimSnapshot, Triangle } from "@actuarial-ts/core";
|
|
|
24
24
|
|
|
25
25
|
export type DataCheckStatus = "pass" | "warning" | "fail" | "not-evaluated";
|
|
26
26
|
|
|
27
|
+
export interface DataFindingContext {
|
|
28
|
+
origin?: string;
|
|
29
|
+
valuation?: string;
|
|
30
|
+
ageMonths?: number;
|
|
31
|
+
group?: string;
|
|
32
|
+
sourceFile?: string;
|
|
33
|
+
sourceRow?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DataFinding {
|
|
37
|
+
/** Stable machine-readable finding code, usually the containing check id. */
|
|
38
|
+
code: string;
|
|
39
|
+
message: string;
|
|
40
|
+
context?: DataFindingContext;
|
|
41
|
+
}
|
|
42
|
+
|
|
27
43
|
export interface DataCheck {
|
|
28
44
|
id: string;
|
|
29
45
|
description: string;
|
|
30
46
|
status: DataCheckStatus;
|
|
31
47
|
details: string[];
|
|
48
|
+
/** Additive structured detail for consumers that should not parse prose. */
|
|
49
|
+
findings?: DataFinding[];
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
export interface DataReviewReport {
|
|
@@ -78,6 +96,39 @@ function summarize(checks: DataCheck[]): DataReviewReport {
|
|
|
78
96
|
return { checks, summary };
|
|
79
97
|
}
|
|
80
98
|
|
|
99
|
+
/** Builds a structured check while retaining the established status/details contract. */
|
|
100
|
+
export function createStructuredDataCheck(
|
|
101
|
+
id: string,
|
|
102
|
+
description: string,
|
|
103
|
+
statusWhenFound: "warning" | "fail",
|
|
104
|
+
findings: readonly DataFinding[],
|
|
105
|
+
): DataCheck {
|
|
106
|
+
const capped = findings.length <= MAX_DETAILS ? [...findings] : [...findings.slice(0, MAX_DETAILS)];
|
|
107
|
+
const details = capped.map((finding) => finding.message);
|
|
108
|
+
if (findings.length > MAX_DETAILS) details.push(`+${findings.length - MAX_DETAILS} more`);
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
description,
|
|
112
|
+
status: findings.length > 0 ? statusWhenFound : "pass",
|
|
113
|
+
details,
|
|
114
|
+
findings: capped,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Public report summarizer for additive review suites. */
|
|
119
|
+
export function summarizeDataChecks(checks: DataCheck[]): DataReviewReport {
|
|
120
|
+
return summarize(checks);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Public not-evaluated constructor so additive suites preserve report semantics. */
|
|
124
|
+
export function createNotEvaluatedDataCheck(
|
|
125
|
+
id: string,
|
|
126
|
+
description: string,
|
|
127
|
+
reason: string,
|
|
128
|
+
): DataCheck {
|
|
129
|
+
return notEvaluated(id, description, reason);
|
|
130
|
+
}
|
|
131
|
+
|
|
81
132
|
const CLAIM_DESCRIPTIONS = {
|
|
82
133
|
"non-finite-value": "Every money field is a finite number (no NaN or Infinity)",
|
|
83
134
|
"negative-paid": "Cumulative paid amounts are non-negative",
|
|
@@ -105,9 +156,9 @@ export function reviewClaimData(
|
|
|
105
156
|
// Identified by claimId + evaluation date, which the DATA carries. The old
|
|
106
157
|
// label fabricated "row N" from this array's index — but this function
|
|
107
158
|
// receives parsed claims, not the file, and parseLossRunCsv numbers real
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
159
|
+
// physical file lines in its own errors. Two different "row" meanings
|
|
160
|
+
// pointed auditors at the wrong line; an identifier we cannot compute is
|
|
161
|
+
// one we must not print.
|
|
111
162
|
const where = `claim ${c.claimId} (eval ${c.evaluationDate})`;
|
|
112
163
|
if (c.paidToDate < 0) {
|
|
113
164
|
negativePaid.push(`${where}: paid_to_date ${c.paidToDate}`);
|