@actuarial-ts/core 0.1.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.
Files changed (51) hide show
  1. package/README.md +11 -4
  2. package/dist/canonical.d.ts +19 -0
  3. package/dist/canonical.d.ts.map +1 -0
  4. package/dist/canonical.js +118 -0
  5. package/dist/canonical.js.map +1 -0
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mack.d.ts.map +1 -1
  11. package/dist/mack.js +58 -18
  12. package/dist/mack.js.map +1 -1
  13. package/dist/odpBootstrap.d.ts +8 -2
  14. package/dist/odpBootstrap.d.ts.map +1 -1
  15. package/dist/odpBootstrap.js +11 -2
  16. package/dist/odpBootstrap.js.map +1 -1
  17. package/dist/types.d.ts +1 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/dist/types.js +4 -0
  20. package/dist/types.js.map +1 -1
  21. package/package.json +4 -2
  22. package/src/benktander.ts +90 -0
  23. package/src/berquist.ts +349 -0
  24. package/src/bf.ts +129 -0
  25. package/src/canonical.ts +122 -0
  26. package/src/capping.ts +295 -0
  27. package/src/caseOutstanding.ts +270 -0
  28. package/src/chainladder.ts +101 -0
  29. package/src/clark.ts +719 -0
  30. package/src/diagnostics.ts +435 -0
  31. package/src/discounting.ts +417 -0
  32. package/src/elrMethods.ts +257 -0
  33. package/src/factors.ts +147 -0
  34. package/src/fisherLange.ts +374 -0
  35. package/src/freqSev.ts +152 -0
  36. package/src/ilf.ts +567 -0
  37. package/src/index.ts +29 -0
  38. package/src/mack.ts +329 -0
  39. package/src/merzWuthrich.ts +147 -0
  40. package/src/munichChainLadder.ts +398 -0
  41. package/src/odpBootstrap.ts +337 -0
  42. package/src/onlevel.ts +155 -0
  43. package/src/salvageSubro.ts +205 -0
  44. package/src/stochastic.ts +151 -0
  45. package/src/tail.ts +156 -0
  46. package/src/trend.ts +150 -0
  47. package/src/triangle.ts +235 -0
  48. package/src/triangleAlgebra.ts +111 -0
  49. package/src/types.ts +357 -0
  50. package/src/ulae.ts +326 -0
  51. package/src/util.ts +68 -0
@@ -0,0 +1,151 @@
1
+ import { ReservingError } from "./types.js";
2
+ import { isNum } from "./util.js";
3
+
4
+ /**
5
+ * Stochastic infrastructure: a seeded, reproducible RNG and the shared
6
+ * result shape every simulation-based method returns.
7
+ *
8
+ * Ground truth:
9
+ * - NO ambient randomness anywhere in the engine: every stochastic method
10
+ * takes an explicit integer seed, and the same seed + same inputs must
11
+ * reproduce the same output bit for bit (the reproducibility-bundle
12
+ * contract depends on it).
13
+ * - The generator is mulberry32: tiny, fast, well-distributed for
14
+ * simulation purposes. It is NOT cryptographic and does not need to be.
15
+ */
16
+
17
+ export interface Rng {
18
+ /** Uniform on [0, 1). */
19
+ next(): number;
20
+ /** Standard normal (Box-Muller with cached spare). */
21
+ normal(): number;
22
+ /**
23
+ * Gamma(shape, scale = 1) via Marsaglia-Tsang squeeze (shape >= 1) with
24
+ * the Ahrens-Dieter boost for shape < 1.
25
+ */
26
+ gamma(shape: number): number;
27
+ }
28
+
29
+ /** Deterministic seeded RNG. Same seed = same stream, forever. */
30
+ export function createRng(seed: number): Rng {
31
+ if (!Number.isInteger(seed)) {
32
+ throw new ReservingError("BAD_SEED", "The RNG seed must be an integer");
33
+ }
34
+ let a = seed >>> 0;
35
+ const next = (): number => {
36
+ a = (a + 0x6d2b79f5) | 0;
37
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
38
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
39
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
40
+ };
41
+
42
+ let spare: number | null = null;
43
+ const normal = (): number => {
44
+ if (spare !== null) {
45
+ const v = spare;
46
+ spare = null;
47
+ return v;
48
+ }
49
+ // Box-Muller; u clamped away from 0 so log stays finite.
50
+ let u = next();
51
+ if (u < 1e-12) u = 1e-12;
52
+ const v = next();
53
+ const r = Math.sqrt(-2 * Math.log(u));
54
+ const theta = 2 * Math.PI * v;
55
+ spare = r * Math.sin(theta);
56
+ return r * Math.cos(theta);
57
+ };
58
+
59
+ const gamma = (shape: number): number => {
60
+ if (!isNum(shape) || shape <= 0) {
61
+ throw new ReservingError("BAD_SHAPE", "Gamma shape must be a positive number");
62
+ }
63
+ if (shape < 1) {
64
+ // Ahrens-Dieter boost: G(a) = G(a+1) * U^(1/a).
65
+ const u = Math.max(next(), 1e-12);
66
+ return gamma(shape + 1) * Math.pow(u, 1 / shape);
67
+ }
68
+ // Marsaglia & Tsang (2000).
69
+ const d = shape - 1 / 3;
70
+ const c = 1 / Math.sqrt(9 * d);
71
+ for (;;) {
72
+ let x: number;
73
+ let v: number;
74
+ do {
75
+ x = normal();
76
+ v = 1 + c * x;
77
+ } while (v <= 0);
78
+ v = v * v * v;
79
+ const u = next();
80
+ if (u < 1 - 0.0331 * x * x * x * x) return d * v;
81
+ if (Math.log(Math.max(u, 1e-300)) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
82
+ }
83
+ };
84
+
85
+ return { next, normal, gamma };
86
+ }
87
+
88
+ /** The percentiles every stochastic summary reports, as fractions. */
89
+ export const STANDARD_PERCENTILES = [0.01, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.99] as const;
90
+
91
+ export interface StochasticSummary {
92
+ mean: number;
93
+ /** Sample standard deviation (n - 1). */
94
+ sd: number;
95
+ /** sd / |mean|; null when the mean is 0. */
96
+ cv: number | null;
97
+ /** Keyed "p50", "p75", ... per STANDARD_PERCENTILES; linear interpolation. */
98
+ percentiles: Record<string, number>;
99
+ }
100
+
101
+ export interface StochasticOriginResult {
102
+ origin: string;
103
+ summary: StochasticSummary;
104
+ }
105
+
106
+ export interface StochasticResult {
107
+ /** What the simulated quantity IS (e.g. "unpaid", "ultimate", "cdr"). */
108
+ quantity: string;
109
+ seed: number;
110
+ nSims: number;
111
+ total: StochasticSummary;
112
+ byOrigin: StochasticOriginResult[];
113
+ /** Total-level simulated values, ascending, for caller-side percentiles/plots. */
114
+ totalSamples: number[];
115
+ warnings: string[];
116
+ }
117
+
118
+ /** Linear-interpolated percentile of a SORTED ascending sample. */
119
+ export function percentileOfSorted(sorted: number[], p: number): number {
120
+ if (sorted.length === 0) {
121
+ throw new ReservingError("NO_DATA", "Cannot take a percentile of an empty sample");
122
+ }
123
+ if (!isNum(p) || p < 0 || p > 1) {
124
+ throw new ReservingError("BAD_PERCENTILE", `Percentile must be in [0, 1] (got ${p})`);
125
+ }
126
+ const idx = p * (sorted.length - 1);
127
+ const lo = Math.floor(idx);
128
+ const hi = Math.ceil(idx);
129
+ if (lo === hi) return sorted[lo]!;
130
+ return sorted[lo]! + (idx - lo) * (sorted[hi]! - sorted[lo]!);
131
+ }
132
+
133
+ /** Summarizes a sample (sorts a copy; the input is not mutated). */
134
+ export function summarizeSample(values: number[]): StochasticSummary {
135
+ if (values.length < 2) {
136
+ throw new ReservingError("TOO_SMALL", "A stochastic summary needs at least two simulations");
137
+ }
138
+ const sorted = [...values].sort((a, b) => a - b);
139
+ const n = sorted.length;
140
+ let sum = 0;
141
+ for (const v of sorted) sum += v;
142
+ const mean = sum / n;
143
+ let ss = 0;
144
+ for (const v of sorted) ss += (v - mean) ** 2;
145
+ const sd = Math.sqrt(ss / (n - 1));
146
+ const percentiles: Record<string, number> = {};
147
+ for (const p of STANDARD_PERCENTILES) {
148
+ percentiles[`p${Math.round(p * 100)}`] = percentileOfSorted(sorted, p);
149
+ }
150
+ return { mean, sd, cv: mean === 0 ? null : sd / Math.abs(mean), percentiles };
151
+ }
package/src/tail.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { TailFit, TailMethod } from "./types.js";
2
+ import { isNum, ols } from "./util.js";
3
+
4
+ /**
5
+ * Tail factor curve fitting per Sherman (1984) and Boor (2006).
6
+ *
7
+ * - Exponential decay: ln(LDF_j - 1) is linear in the development period index.
8
+ * - Inverse power: ln(LDF_j - 1) is linear in ln(period index).
9
+ *
10
+ * The fit runs on the SELECTED age-to-age factors. Guards:
11
+ * - fewer than MIN_POINTS usable points (factor > 1) -> invalid;
12
+ * - non-negative slope (growth instead of decay) -> invalid;
13
+ * - extrapolation is capped in horizon and in total tail size, with a loud
14
+ * warning when the cap binds (inverse power with slope > -1 diverges).
15
+ */
16
+
17
+ const MIN_POINTS = 3;
18
+ /** Extrapolate at most this many development periods beyond the last age. */
19
+ const MAX_HORIZON = 200;
20
+ /** Stop extrapolating once the incremental factor is this close to 1. */
21
+ const CONVERGENCE_EPS = 1e-7;
22
+ /** A fitted tail beyond this bound is treated as divergent. */
23
+ const MAX_TAIL = 5;
24
+
25
+ export interface FitTailOptions {
26
+ method: TailMethod;
27
+ /**
28
+ * Selected LDFs by development column (from ages[j] to ages[j+1]);
29
+ * null entries are skipped.
30
+ */
31
+ selectedLdfs: (number | null)[];
32
+ /** Cap on extrapolated development periods (default 200). */
33
+ maxHorizon?: number;
34
+ }
35
+
36
+ export function fitTail(options: FitTailOptions): TailFit {
37
+ const { method, selectedLdfs } = options;
38
+ const maxHorizon = options.maxHorizon ?? MAX_HORIZON;
39
+ const warnings: string[] = [];
40
+
41
+ // x = 1-based development period index of each factor; y = ln(f - 1).
42
+ const xs: number[] = [];
43
+ const ys: number[] = [];
44
+ let skippedAtOrBelowOne = 0;
45
+ for (let j = 0; j < selectedLdfs.length; j++) {
46
+ const f = selectedLdfs[j] ?? null;
47
+ if (!isNum(f)) continue;
48
+ if (f <= 1) {
49
+ skippedAtOrBelowOne++;
50
+ continue;
51
+ }
52
+ const idx = j + 1;
53
+ xs.push(method === "exponentialDecay" ? idx : Math.log(idx));
54
+ ys.push(Math.log(f - 1));
55
+ }
56
+ if (skippedAtOrBelowOne > 0) {
57
+ warnings.push(
58
+ `${skippedAtOrBelowOne} selected factor(s) at or below 1.000 were excluded from the fit (ln(f-1) undefined)`,
59
+ );
60
+ }
61
+
62
+ const invalid = (message: string): TailFit => ({
63
+ method,
64
+ intercept: NaN,
65
+ slope: NaN,
66
+ rSquared: NaN,
67
+ nPoints: xs.length,
68
+ extrapolatedFactors: [],
69
+ tailFactor: 1,
70
+ valid: false,
71
+ warnings: [...warnings, message],
72
+ });
73
+
74
+ if (xs.length < MIN_POINTS) {
75
+ return invalid(
76
+ `Only ${xs.length} usable point(s); at least ${MIN_POINTS} factors above 1.000 are required to fit a tail curve`,
77
+ );
78
+ }
79
+ const fit = ols(xs, ys);
80
+ if (!fit) return invalid("Degenerate regression (no variation in development ages)");
81
+ if (fit.slope >= 0) {
82
+ return invalid(
83
+ "Fitted curve grows with age instead of decaying; the selected factors do not support a tail extrapolation with this model",
84
+ );
85
+ }
86
+
87
+ // Extrapolate incremental factors beyond the last SELECTED column. Trailing
88
+ // null selections carry no development of their own (chain ladder treats
89
+ // them as 1.000), so the extrapolation must start right after the last
90
+ // non-null selection or the curve's predicted factors for those columns
91
+ // would silently vanish from the tail.
92
+ let lastIdx = 0; // 1-based index of the last non-null selection
93
+ for (let j = selectedLdfs.length - 1; j >= 0; j--) {
94
+ if (isNum(selectedLdfs[j] ?? null)) {
95
+ lastIdx = j + 1;
96
+ break;
97
+ }
98
+ }
99
+ if (lastIdx < selectedLdfs.length) {
100
+ warnings.push(
101
+ `The last ${selectedLdfs.length - lastIdx} development column(s) have no selected factor; the fitted tail covers them via the curve (chain ladder would otherwise treat them as 1.000)`,
102
+ );
103
+ }
104
+ const extrapolatedFactors: number[] = [];
105
+ let tail = 1;
106
+ let converged = false;
107
+ for (let step = 1; step <= maxHorizon; step++) {
108
+ const idx = lastIdx + step;
109
+ const x = method === "exponentialDecay" ? idx : Math.log(idx);
110
+ const f = 1 + Math.exp(fit.intercept + fit.slope * x);
111
+ extrapolatedFactors.push(f);
112
+ tail *= f;
113
+ if (tail > MAX_TAIL) {
114
+ return invalid(
115
+ `Extrapolated tail exceeded ${MAX_TAIL.toFixed(1)} after ${step} periods; the fit is divergent (inverse power with slope > -1 has an unbounded product). Enter a tail judgmentally instead.`,
116
+ );
117
+ }
118
+ if (f - 1 < CONVERGENCE_EPS) {
119
+ converged = true;
120
+ break;
121
+ }
122
+ }
123
+ if (!converged) {
124
+ warnings.push(
125
+ `Extrapolation stopped at the ${maxHorizon}-period horizon before the factors converged to 1.000; the tail is truncated and understated`,
126
+ );
127
+ }
128
+
129
+ if (fit.rSquared < 0.8) {
130
+ warnings.push(
131
+ `Fit quality is weak (R-squared ${fit.rSquared.toFixed(3)}); treat this tail as indicative only`,
132
+ );
133
+ }
134
+
135
+ return {
136
+ method,
137
+ intercept: fit.intercept,
138
+ slope: fit.slope,
139
+ rSquared: fit.rSquared,
140
+ nPoints: fit.n,
141
+ extrapolatedFactors,
142
+ tailFactor: tail,
143
+ valid: true,
144
+ warnings,
145
+ };
146
+ }
147
+
148
+ /** Fits both supported curves so the user can compare them side by side. */
149
+ export function fitAllTails(
150
+ selectedLdfs: (number | null)[],
151
+ ): { exponentialDecay: TailFit; inversePower: TailFit } {
152
+ return {
153
+ exponentialDecay: fitTail({ method: "exponentialDecay", selectedLdfs }),
154
+ inversePower: fitTail({ method: "inversePower", selectedLdfs }),
155
+ };
156
+ }
package/src/trend.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { ReservingError } from "./types.js";
2
+ import { isNum, ols } from "./util.js";
3
+
4
+ /**
5
+ * Trend machinery: log-linear regressions on year-indexed actuarial series
6
+ * (ultimate frequency, severity, pure premium), with the same menu
7
+ * discipline as the LDF exhibit - several fitted windows plus judgment.
8
+ *
9
+ * ln(y) = a + b·t fitted by ordinary least squares; the annual trend is
10
+ * e^b - 1. R-squared is reported so thin or noisy series are judged, not
11
+ * trusted blindly.
12
+ */
13
+
14
+ export interface TrendPoint {
15
+ /** Origin year (the regression x, centered internally). */
16
+ year: number;
17
+ /** The series value (must be positive to enter the log fit). */
18
+ value: number | null;
19
+ }
20
+
21
+ export interface TrendFit {
22
+ key: string;
23
+ label: string;
24
+ /** Annual trend rate, e.g. 0.05 = +5%/yr; null when the window can't fit. */
25
+ annualRate: number | null;
26
+ rSquared: number | null;
27
+ nPoints: number;
28
+ /** Yearly values the window actually used. */
29
+ usedYears: number[];
30
+ warnings: string[];
31
+ }
32
+
33
+ export interface TrendAnalysis {
34
+ points: TrendPoint[];
35
+ fits: TrendFit[];
36
+ }
37
+
38
+ function fitWindow(
39
+ points: { year: number; value: number }[],
40
+ key: string,
41
+ label: string,
42
+ ): TrendFit {
43
+ const warnings: string[] = [];
44
+ if (points.length < 3) {
45
+ return {
46
+ key,
47
+ label,
48
+ annualRate: null,
49
+ rSquared: null,
50
+ nPoints: points.length,
51
+ usedYears: points.map((p) => p.year),
52
+ warnings: ["Fewer than 3 usable points; no fit"],
53
+ };
54
+ }
55
+ const xs = points.map((p) => p.year);
56
+ const ys = points.map((p) => Math.log(p.value));
57
+ // n >= 3 here, so a null fit can only mean zero variation in years.
58
+ const fit = ols(xs, ys);
59
+ if (fit === null) {
60
+ return {
61
+ key,
62
+ label,
63
+ annualRate: null,
64
+ rSquared: null,
65
+ nPoints: points.length,
66
+ usedYears: xs,
67
+ warnings: ["No variation in years; no fit"],
68
+ };
69
+ }
70
+ if (points.length < 5) {
71
+ warnings.push(`Only ${points.length} points; the fitted trend is volatile`);
72
+ }
73
+ return {
74
+ key,
75
+ label,
76
+ annualRate: Math.exp(fit.slope) - 1,
77
+ rSquared: fit.rSquared,
78
+ nPoints: points.length,
79
+ usedYears: xs,
80
+ warnings,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Fits the standard windows over a year-indexed series: all years, last 5
86
+ * YEARS, last 3 YEARS, and all years excluding the highest and lowest values
87
+ * (the ex-hi-lo medial convention). Windows are sized in POINTS PER YEAR so
88
+ * a quarterly series' "Last 5 years" really spans 5 years (20 quarters), not
89
+ * 5 points. Non-positive and missing values are excluded from every window
90
+ * (a log fit cannot see them), with a warning.
91
+ */
92
+ export function analyzeTrend(points: TrendPoint[], pointsPerYear = 1): TrendAnalysis {
93
+ const usable = points
94
+ .filter((p): p is { year: number; value: number } => isNum(p.value) && p.value! > 0)
95
+ .sort((a, b) => a.year - b.year);
96
+ const excluded = points.length - usable.length;
97
+
98
+ const fits: TrendFit[] = [];
99
+ const base = (key: string, label: string, pts: { year: number; value: number }[]) => {
100
+ const fit = fitWindow(pts, key, label);
101
+ if (excluded > 0) {
102
+ fit.warnings.push(`${excluded} missing/non-positive year(s) excluded from the series`);
103
+ }
104
+ fits.push(fit);
105
+ };
106
+
107
+ const ppy = Math.max(1, Math.round(pointsPerYear));
108
+ base("all", "All years", usable);
109
+ base("last5", "Last 5 years", usable.slice(-5 * ppy));
110
+ base("last3", "Last 3 years", usable.slice(-3 * ppy));
111
+ if (usable.length >= 5) {
112
+ const sortedByValue = [...usable].sort((a, b) => a.value - b.value);
113
+ const hi = sortedByValue[sortedByValue.length - 1]!;
114
+ const lo = sortedByValue[0]!;
115
+ base(
116
+ "exhilo",
117
+ "Ex high/low",
118
+ usable.filter((p) => p !== hi && p !== lo),
119
+ );
120
+ } else {
121
+ fits.push({
122
+ key: "exhilo",
123
+ label: "Ex high/low",
124
+ annualRate: null,
125
+ rSquared: null,
126
+ nPoints: usable.length,
127
+ usedYears: [],
128
+ warnings: ["Needs at least 5 usable points"],
129
+ });
130
+ }
131
+
132
+ return { points, fits };
133
+ }
134
+
135
+ /**
136
+ * Trend a value from one year's cost level to another's:
137
+ * value × (1 + rate)^(toYear − fromYear). Midpoint-to-midpoint conventions
138
+ * are the caller's responsibility (whole years in, whole years out).
139
+ */
140
+ export function trendValue(
141
+ value: number,
142
+ rate: number,
143
+ fromYear: number,
144
+ toYear: number,
145
+ ): number {
146
+ if (!isNum(rate) || rate <= -1) {
147
+ throw new ReservingError("BAD_TREND", "A trend rate must be a number greater than -100%");
148
+ }
149
+ return value * Math.pow(1 + rate, toYear - fromYear);
150
+ }
@@ -0,0 +1,235 @@
1
+ import type {
2
+ ClaimSnapshot,
3
+ OriginCadence,
4
+ Triangle,
5
+ TriangleKind,
6
+ } from "./types.js";
7
+ import { ReservingError } from "./types.js";
8
+
9
+ /**
10
+ * Builds development triangles from claim-level evaluation snapshots.
11
+ *
12
+ * Conventions:
13
+ * - A claim belongs to the origin period containing its accident date.
14
+ * - Development age is measured in months from the start of the origin
15
+ * period; the age-m evaluation date is the last day of month (start + m - 1).
16
+ * - A cell is observable only when its evaluation date is on or before the
17
+ * as-of date; unobservable cells are null.
18
+ * - A claim's state at an evaluation date is the latest snapshot on or
19
+ * before that date (step function). A reported claim with no snapshot yet
20
+ * counts as reported/open with zero paid and zero case.
21
+ */
22
+
23
+ interface ParsedDate {
24
+ y: number;
25
+ m: number; // 1-12
26
+ d: number;
27
+ }
28
+
29
+ function parseISO(date: string): ParsedDate {
30
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
31
+ if (!match) {
32
+ throw new ReservingError("BAD_DATE", `Invalid ISO date: "${date}"`);
33
+ }
34
+ const y = Number(match[1]);
35
+ const m = Number(match[2]);
36
+ const d = Number(match[3]);
37
+ if (m < 1 || m > 12 || d < 1 || d > 31) {
38
+ throw new ReservingError("BAD_DATE", `Invalid ISO date: "${date}"`);
39
+ }
40
+ return { y, m, d };
41
+ }
42
+
43
+ function monthIndex(p: ParsedDate): number {
44
+ return p.y * 12 + (p.m - 1);
45
+ }
46
+
47
+ function daysInMonth(y: number, m: number): number {
48
+ return new Date(Date.UTC(y, m, 0)).getUTCDate();
49
+ }
50
+
51
+ /** ISO date of the last day of the month holding this month index. */
52
+ function endOfMonthISO(mIdx: number): string {
53
+ const y = Math.floor(mIdx / 12);
54
+ const m = (mIdx % 12) + 1;
55
+ const d = daysInMonth(y, m);
56
+ return `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
57
+ }
58
+
59
+ function periodIndexOf(p: ParsedDate, cadence: OriginCadence): number {
60
+ return cadence === "annual" ? p.y : p.y * 4 + Math.floor((p.m - 1) / 3);
61
+ }
62
+
63
+ function periodLabel(index: number, cadence: OriginCadence): string {
64
+ if (cadence === "annual") return String(index);
65
+ const y = Math.floor(index / 4);
66
+ const q = (index % 4) + 1;
67
+ return `${y}Q${q}`;
68
+ }
69
+
70
+ /** Month index of the first month of an origin period. */
71
+ function periodStartMonth(index: number, cadence: OriginCadence): number {
72
+ return cadence === "annual" ? index * 12 : index * 3;
73
+ }
74
+
75
+ export interface BuildTrianglesOptions {
76
+ cadence: OriginCadence;
77
+ /** ISO evaluation date of the analysis (the latest diagonal). */
78
+ asOfDate: string;
79
+ }
80
+
81
+ export interface TriangleSet {
82
+ paid: Triangle;
83
+ incurred: Triangle;
84
+ caseReserve: Triangle;
85
+ reportedCount: Triangle;
86
+ openCount: Triangle;
87
+ closedCount: Triangle;
88
+ closedWithPayCount: Triangle;
89
+ }
90
+
91
+ interface ClaimTimeline {
92
+ originIdx: number;
93
+ reportISO: string;
94
+ /** Snapshots sorted ascending by evaluation date. */
95
+ snapshots: ClaimSnapshot[];
96
+ }
97
+
98
+ export function buildTriangles(
99
+ claims: ClaimSnapshot[],
100
+ options: BuildTrianglesOptions,
101
+ ): TriangleSet {
102
+ const { cadence, asOfDate } = options;
103
+ if (claims.length === 0) {
104
+ throw new ReservingError("NO_CLAIMS", "Cannot build triangles from an empty loss run");
105
+ }
106
+ const asOf = parseISO(asOfDate);
107
+ const asOfMonth = monthIndex(asOf);
108
+ const asOfIsMonthEnd = asOf.d === daysInMonth(asOf.y, asOf.m);
109
+ // The latest complete evaluation month.
110
+ const lastCompleteMonth = asOfIsMonthEnd ? asOfMonth : asOfMonth - 1;
111
+ const cadenceMonths = cadence === "annual" ? 12 : 3;
112
+
113
+ // Group snapshots into claim timelines and find the origin period range.
114
+ const byClaim = new Map<string, ClaimTimeline>();
115
+ let minPeriod = Infinity;
116
+ let maxPeriod = -Infinity;
117
+ for (const snap of claims) {
118
+ const accident = parseISO(snap.accidentDate);
119
+ if (snap.evaluationDate > asOfDate) continue; // beyond the analysis date
120
+ const period = periodIndexOf(accident, cadence);
121
+ minPeriod = Math.min(minPeriod, period);
122
+ maxPeriod = Math.max(maxPeriod, period);
123
+ let timeline = byClaim.get(snap.claimId);
124
+ if (!timeline) {
125
+ timeline = { originIdx: period, reportISO: snap.reportDate, snapshots: [] };
126
+ byClaim.set(snap.claimId, timeline);
127
+ }
128
+ timeline.snapshots.push(snap);
129
+ }
130
+ if (!Number.isFinite(minPeriod)) {
131
+ throw new ReservingError(
132
+ "NO_CLAIMS",
133
+ "No claim snapshots fall on or before the as-of date",
134
+ );
135
+ }
136
+ for (const timeline of byClaim.values()) {
137
+ timeline.snapshots.sort((a, b) =>
138
+ a.evaluationDate < b.evaluationDate ? -1 : a.evaluationDate > b.evaluationDate ? 1 : 0,
139
+ );
140
+ }
141
+
142
+ const nOrigins = maxPeriod - minPeriod + 1;
143
+ const origins: string[] = [];
144
+ for (let p = minPeriod; p <= maxPeriod; p++) origins.push(periodLabel(p, cadence));
145
+
146
+ // Ages available to the oldest origin period determine the column count.
147
+ const oldestStart = periodStartMonth(minPeriod, cadence);
148
+ const maxAge = lastCompleteMonth - oldestStart + 1;
149
+ const nAges = Math.floor(maxAge / cadenceMonths);
150
+ if (nAges < 1) {
151
+ throw new ReservingError(
152
+ "NO_DEVELOPMENT",
153
+ "The as-of date precedes the first complete development age",
154
+ );
155
+ }
156
+ const ages: number[] = [];
157
+ for (let j = 1; j <= nAges; j++) ages.push(j * cadenceMonths);
158
+
159
+ const mk = (kind: TriangleKind): Triangle => ({
160
+ kind,
161
+ origins: [...origins],
162
+ ages: [...ages],
163
+ values: Array.from({ length: nOrigins }, (_, i) =>
164
+ ages.map((age) => {
165
+ const evalMonth = periodStartMonth(minPeriod + i, cadence) + age - 1;
166
+ return evalMonth <= lastCompleteMonth ? 0 : null;
167
+ }),
168
+ ),
169
+ });
170
+
171
+ const set: TriangleSet = {
172
+ paid: mk("paid"),
173
+ incurred: mk("incurred"),
174
+ caseReserve: mk("caseReserve"),
175
+ reportedCount: mk("reportedCount"),
176
+ openCount: mk("openCount"),
177
+ closedCount: mk("closedCount"),
178
+ closedWithPayCount: mk("closedWithPayCount"),
179
+ };
180
+
181
+ const add = (tri: Triangle, i: number, j: number, v: number) => {
182
+ const cell = tri.values[i]![j];
183
+ if (cell !== null && cell !== undefined) tri.values[i]![j] = cell + v;
184
+ };
185
+
186
+ for (const timeline of byClaim.values()) {
187
+ const i = timeline.originIdx - minPeriod;
188
+ const originStart = periodStartMonth(timeline.originIdx, cadence);
189
+ for (let j = 0; j < nAges; j++) {
190
+ const evalMonth = originStart + ages[j]! - 1;
191
+ if (evalMonth > lastCompleteMonth) break; // this and later cells are null
192
+ const evalISO = endOfMonthISO(evalMonth);
193
+ if (timeline.reportISO > evalISO) continue; // not yet reported
194
+ // Latest snapshot on or before the cell's evaluation date.
195
+ let state: ClaimSnapshot | null = null;
196
+ for (const snap of timeline.snapshots) {
197
+ if (snap.evaluationDate <= evalISO) state = snap;
198
+ else break;
199
+ }
200
+ const paid = state?.paidToDate ?? 0;
201
+ const caseReserve = state?.status === "open" ? (state?.caseReserve ?? 0) : 0;
202
+ const isClosed = state?.status === "closed";
203
+ add(set.reportedCount, i, j, 1);
204
+ add(set.paid, i, j, paid);
205
+ add(set.caseReserve, i, j, caseReserve);
206
+ add(set.incurred, i, j, paid + caseReserve);
207
+ if (isClosed) {
208
+ add(set.closedCount, i, j, 1);
209
+ if (paid > 0) add(set.closedWithPayCount, i, j, 1);
210
+ } else {
211
+ add(set.openCount, i, j, 1);
212
+ }
213
+ }
214
+ }
215
+
216
+ return set;
217
+ }
218
+
219
+ /** Constructs a triangle directly from a grid of values (import path). */
220
+ export function triangleFromGrid(
221
+ kind: TriangleKind,
222
+ origins: string[],
223
+ ages: number[],
224
+ values: (number | null)[][],
225
+ ): Triangle {
226
+ if (values.length !== origins.length) {
227
+ throw new ReservingError("SHAPE", "Row count does not match origin count");
228
+ }
229
+ for (const row of values) {
230
+ if (row.length !== ages.length) {
231
+ throw new ReservingError("SHAPE", "Column count does not match age count");
232
+ }
233
+ }
234
+ return { kind, origins: [...origins], ages: [...ages], values: values.map((r) => [...r]) };
235
+ }