@fin.cx/opendata 4.3.1 → 4.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.
@@ -0,0 +1,292 @@
1
+ import { CpiError } from './classes.cpierror.js';
2
+ import {
3
+ bundesbankCpiBaseUrl,
4
+ bundesbankVpiAnnualKey,
5
+ bundesbankVpiFirstMonth,
6
+ bundesbankVpiFirstYear,
7
+ bundesbankVpiMonthlyKey,
8
+ destatisLicence,
9
+ } from './constants.js';
10
+ import { parseDecimal } from './decimal.js';
11
+ import { frequencyOf } from './indexmath.js';
12
+ import type {
13
+ ICpiAnnualInput,
14
+ ICpiClientOptions,
15
+ ICpiMonthlyInput,
16
+ ICpiReading,
17
+ ICpiSeries,
18
+ ICpiSourceNote,
19
+ ICpiValue,
20
+ TCpiFrequency,
21
+ TCpiPeriod,
22
+ } from './interfaces.js';
23
+ import { getText, timeoutOf } from './request.js';
24
+
25
+ /**
26
+ * The columns of the API's SDMX-CSV answer this client reads. The API leaves out a column that is empty
27
+ * in every row: `OBS_STATUS` appears only where a value carries a status.
28
+ */
29
+ const COLUMNS = ['BBK_STD_FREQ', 'TIME_PERIOD', 'OBS_VALUE', 'BBK_DECIMALS', 'BBK_ID', 'BBK_UNIT', 'BBK_TITLE'] as const;
30
+ const STATUS_COLUMN = 'OBS_STATUS';
31
+
32
+ /** The key of the overall index for Germany on one base, as the answer names it. */
33
+ const SERIES_KEY = /^BBDP1\.([MA])\.DE\.N\.VPI\.C\.A00000\.I(\d{2})\.A$/u;
34
+
35
+ const UNIT = /^(\d{4})=100$/u;
36
+
37
+ const FREQUENCY_CODES: Readonly<Record<TCpiFrequency, string>> = { monthly: 'M', annual: 'A' };
38
+
39
+ /** What the API's `OBS_STATUS` says about a value; any other flag is not read. */
40
+ const STATUSES: Readonly<Record<string, ICpiValue['status']>> = { '': 'final', E: 'estimated' };
41
+
42
+ interface IRange {
43
+ frequency: TCpiFrequency;
44
+ from: TCpiPeriod;
45
+ to?: TCpiPeriod;
46
+ }
47
+
48
+ interface IReadSeries {
49
+ values: ICpiValue[];
50
+ estimates: ICpiValue[];
51
+ base: string;
52
+ baseYear: number;
53
+ key: string;
54
+ title: string;
55
+ }
56
+
57
+ /** One line of semicolon-separated values; a field in double quotes may hold separators and doubled quotes. */
58
+ const splitLine = (lineArg: string): string[] | undefined => {
59
+ const fields: string[] = [];
60
+ let field = '';
61
+ let quoted = false;
62
+ for (let position = 0; position < lineArg.length; position++) {
63
+ const character = lineArg[position];
64
+ if (quoted) {
65
+ if (character === '"' && lineArg[position + 1] === '"') {
66
+ field += '"';
67
+ position++;
68
+ } else if (character === '"') {
69
+ quoted = false;
70
+ } else {
71
+ field += character;
72
+ }
73
+ } else if (character === '"' && field === '') {
74
+ quoted = true;
75
+ } else if (character === ';') {
76
+ fields.push(field);
77
+ field = '';
78
+ } else {
79
+ field += character;
80
+ }
81
+ }
82
+ if (quoted) {
83
+ return undefined;
84
+ }
85
+ fields.push(field);
86
+ return fields;
87
+ };
88
+
89
+ /**
90
+ * The Verbraucherpreisindex für Deutschland (overall index, unadjusted) of the Statistisches Bundesamt as
91
+ * the Deutsche Bundesbank republishes it in its time series API (`bundesbankCpiBaseUrl`), monthly and as
92
+ * annual averages. Each answer names its base, and the values the Bundesbank marks as its own estimate
93
+ * (the latest month at times) are kept apart from the published ones. Nothing is kept: each answer goes
94
+ * to the caller with the source note to show and the answer as it arrived.
95
+ */
96
+ export class BundesbankCpiClient {
97
+ private readonly baseUrl: string;
98
+
99
+ constructor(private readonly options: ICpiClientOptions = {}) {
100
+ timeoutOf(options);
101
+ this.baseUrl = (options.baseUrl ?? bundesbankCpiBaseUrl).replace(/\/?$/u, '/');
102
+ }
103
+
104
+ /** The months from `from` to `to`, both included, or to the latest one when `to` is left out. */
105
+ public async getMonthly(inputArg: ICpiMonthlyInput): Promise<ICpiSeries> {
106
+ return this.getSeries({ frequency: 'monthly', from: inputArg.from, ...(inputArg.to !== undefined ? { to: inputArg.to } : {}) }, inputArg.signal);
107
+ }
108
+
109
+ /** The annual averages from `from` to `to`, both included, or to the latest one when `to` is left out. */
110
+ public async getAnnual(inputArg: ICpiAnnualInput): Promise<ICpiSeries> {
111
+ return this.getSeries({ frequency: 'annual', from: inputArg.from, ...(inputArg.to !== undefined ? { to: inputArg.to } : {}) }, inputArg.signal);
112
+ }
113
+
114
+ /**
115
+ * The published value of one month. A month with only the Bundesbank's estimate, or with nothing yet,
116
+ * ends in `not_published`, the estimate in the error where there is one.
117
+ */
118
+ public async getMonth(monthArg: string, optionsArg: { signal?: AbortSignal } = {}): Promise<ICpiReading> {
119
+ const series = await this.getSeries({ frequency: 'monthly', from: monthArg, to: monthArg }, optionsArg.signal);
120
+ const value = series.values[0];
121
+ if (!value) {
122
+ const estimate = series.estimates[0];
123
+ const message = estimate
124
+ ? `the index for ${monthArg} is not published yet; the Bundesbank gives only its own estimate (${estimate.value}).`
125
+ : `the index for ${monthArg} is not published yet.`;
126
+ throw new CpiError('not_published', message, {
127
+ service: 'bundesbank',
128
+ httpStatus: series.answer.httpStatus,
129
+ answer: series.answer,
130
+ period: monthArg,
131
+ ...(estimate ? { estimate } : {}),
132
+ });
133
+ }
134
+ return { ...value, source: series.source, answer: series.answer };
135
+ }
136
+
137
+ private async getSeries(rangeArg: IRange, signalArg: AbortSignal | undefined): Promise<ICpiSeries> {
138
+ checkRange(rangeArg);
139
+ const key = rangeArg.frequency === 'monthly' ? bundesbankVpiMonthlyKey : bundesbankVpiAnnualKey;
140
+ const uri = `${this.baseUrl}${key}?startPeriod=${rangeArg.from}${rangeArg.to !== undefined ? `&endPeriod=${rangeArg.to}` : ''}`;
141
+ const answer = await getText(this.options, uri, signalArg);
142
+ const refused = (codeArg: 'series_withdrawn' | 'busy' | 'unavailable' | 'unexpected_answer', messageArg: string): CpiError =>
143
+ new CpiError(codeArg, messageArg, { service: 'bundesbank', httpStatus: answer.httpStatus, answer });
144
+ if (answer.httpStatus === 404) {
145
+ throw refused('series_withdrawn', `the Bundesbank has no series ${key}: it answered 404.`);
146
+ }
147
+ if (answer.httpStatus === 429) {
148
+ throw refused('busy', 'the Bundesbank refused the request as one too many (429).');
149
+ }
150
+ if (answer.httpStatus >= 500 && answer.httpStatus <= 599) {
151
+ throw refused('unavailable', `the Bundesbank cannot answer now (${answer.httpStatus}).`);
152
+ }
153
+ if (answer.httpStatus !== 200) {
154
+ throw refused('unexpected_answer', `the Bundesbank answered ${answer.httpStatus}.`);
155
+ }
156
+ const read = readAnswer(answer.text, rangeArg, (messageArg) => refused('unexpected_answer', messageArg));
157
+ if (!read) {
158
+ throw new CpiError('not_published', `the Bundesbank has no value from ${rangeArg.from}${rangeArg.to !== undefined ? ` to ${rangeArg.to}` : ' on'} yet.`, {
159
+ service: 'bundesbank',
160
+ httpStatus: answer.httpStatus,
161
+ answer,
162
+ period: rangeArg.from,
163
+ });
164
+ }
165
+ return {
166
+ index: 'de-vpi',
167
+ frequency: rangeArg.frequency,
168
+ base: read.base,
169
+ baseYear: read.baseYear,
170
+ values: read.values,
171
+ estimates: read.estimates,
172
+ source: sourceNoteOf(read, uri),
173
+ answer,
174
+ };
175
+ }
176
+ }
177
+
178
+ /** A range of the series' periods, or the reason it is none. */
179
+ const checkRange = (rangeArg: IRange): void => {
180
+ const written = rangeArg.frequency === 'monthly' ? 'YYYY-MM' : 'YYYY';
181
+ for (const period of [rangeArg.from, rangeArg.to]) {
182
+ if (period !== undefined && frequencyOf(period) !== rangeArg.frequency) {
183
+ throw new CpiError('invalid_input', `${JSON.stringify(period)} is no period written ${written}.`);
184
+ }
185
+ }
186
+ if (rangeArg.to !== undefined && rangeArg.to < rangeArg.from) {
187
+ throw new CpiError('invalid_input', `the range ends (${rangeArg.to}) before it begins (${rangeArg.from}).`);
188
+ }
189
+ const first = rangeArg.frequency === 'monthly' ? bundesbankVpiFirstMonth : bundesbankVpiFirstYear;
190
+ if (rangeArg.from < first) {
191
+ throw new CpiError('before_first_period', `${rangeArg.from} lies before ${first}, the first period of the series.`, { period: rangeArg.from });
192
+ }
193
+ };
194
+
195
+ /**
196
+ * The values of an SDMX-CSV answer, or `undefined` where it holds none. An answer is read only where every
197
+ * row is of the series and the frequency asked for, of one key and one base, inside the range asked for,
198
+ * once per period, with a value of the digits the answer states and a status this client knows.
199
+ */
200
+ const readAnswer = (textArg: string, rangeArg: IRange, unreadableArg: (messageArg: string) => CpiError): IReadSeries | undefined => {
201
+ const lines = textArg.replace(/^/u, '').split(/\r?\n/u).filter((lineArg) => lineArg !== '');
202
+ const header = lines[0] === undefined ? undefined : splitLine(lines[0]);
203
+ if (!header || header[0] !== 'DATAFLOW') {
204
+ throw unreadableArg('the answer is no SDMX-CSV of the Bundesbank.');
205
+ }
206
+ const rows = lines.slice(1);
207
+ if (rows.length === 0) {
208
+ return undefined;
209
+ }
210
+ const missing = COLUMNS.filter((columnArg) => !header.includes(columnArg));
211
+ if (missing.length > 0) {
212
+ throw unreadableArg(`the answer has no column ${missing.join(', ')}.`);
213
+ }
214
+ const at = (fieldsArg: string[], columnArg: (typeof COLUMNS)[number] | typeof STATUS_COLUMN): string => {
215
+ const position = header.indexOf(columnArg);
216
+ return position < 0 ? '' : fieldsArg[position] ?? '';
217
+ };
218
+ const values: ICpiValue[] = [];
219
+ const estimates: ICpiValue[] = [];
220
+ const periods = new Set<string>();
221
+ let key: string | undefined;
222
+ let title = '';
223
+ let base = '';
224
+ let baseYear = 0;
225
+ for (const row of rows) {
226
+ const fields = splitLine(row);
227
+ if (!fields || fields.length !== header.length) {
228
+ throw unreadableArg(`a row is not of the answer's columns: ${row}`);
229
+ }
230
+ const rowKey = at(fields, 'BBK_ID');
231
+ const keyMatch = SERIES_KEY.exec(rowKey);
232
+ const unitMatch = UNIT.exec(at(fields, 'BBK_UNIT'));
233
+ const frequencyCode = FREQUENCY_CODES[rangeArg.frequency];
234
+ if (!keyMatch || keyMatch[1] !== frequencyCode || at(fields, 'BBK_STD_FREQ') !== frequencyCode) {
235
+ throw unreadableArg(`${JSON.stringify(rowKey)} is not the ${rangeArg.frequency} series of the overall index for Germany.`);
236
+ }
237
+ if (!unitMatch || unitMatch[1].slice(2) !== keyMatch[2]) {
238
+ throw unreadableArg(`${JSON.stringify(at(fields, 'BBK_UNIT'))} is no base of the series ${rowKey}.`);
239
+ }
240
+ if (key !== undefined && key !== rowKey) {
241
+ throw unreadableArg(`the answer holds more than one series or base: ${key} and ${rowKey}.`);
242
+ }
243
+ key = rowKey;
244
+ title = at(fields, 'BBK_TITLE');
245
+ base = unitMatch[0];
246
+ baseYear = Number(unitMatch[1]);
247
+ const period = at(fields, 'TIME_PERIOD');
248
+ if (frequencyOf(period) !== rangeArg.frequency || period < rangeArg.from || (rangeArg.to !== undefined && period > rangeArg.to)) {
249
+ throw unreadableArg(`${JSON.stringify(period)} is not a period asked for.`);
250
+ }
251
+ if (periods.has(period)) {
252
+ throw unreadableArg(`${period} is answered twice.`);
253
+ }
254
+ periods.add(period);
255
+ const flag = at(fields, STATUS_COLUMN);
256
+ if (!Object.hasOwn(STATUSES, flag)) {
257
+ throw unreadableArg(`${JSON.stringify(flag)} is no status this client knows, for ${period}.`);
258
+ }
259
+ const status = STATUSES[flag];
260
+ const written = at(fields, 'OBS_VALUE');
261
+ if (written === '' || written === 'NaN') {
262
+ // a period without a value has no observation
263
+ continue;
264
+ }
265
+ const decimals = Number(at(fields, 'BBK_DECIMALS'));
266
+ const decimal = parseDecimal(written);
267
+ if (!Number.isSafeInteger(decimals) || decimals < 0 || decimals > 6 || !decimal || decimal.units <= 0n || decimal.scale > decimals) {
268
+ throw unreadableArg(`${JSON.stringify(written)} is no index value of ${at(fields, 'BBK_DECIMALS')} decimals, for ${period}.`);
269
+ }
270
+ const value = decimal.scale === decimals ? written : `${written}${decimal.scale === 0 ? '.' : ''}${'0'.repeat(decimals - decimal.scale)}`;
271
+ (status === 'final' ? values : estimates).push({ index: 'de-vpi', period, value, base, status });
272
+ }
273
+ if (key === undefined || values.length + estimates.length === 0) {
274
+ return undefined;
275
+ }
276
+ const byPeriod = (leftArg: ICpiValue, rightArg: ICpiValue): number => leftArg.period.localeCompare(rightArg.period);
277
+ return { values: values.sort(byPeriod), estimates: estimates.sort(byPeriod), base, baseYear, key, title };
278
+ };
279
+
280
+ const sourceNoteOf = (readArg: IReadSeries, uriArg: string): ICpiSourceNote => ({
281
+ source: 'bundesbank',
282
+ provider: 'Deutsche Bundesbank',
283
+ dataOrigin: 'Statistisches Bundesamt (Destatis)',
284
+ title: readArg.title,
285
+ key: readArg.key,
286
+ uri: uriArg,
287
+ licence: destatisLicence.name,
288
+ licenceUri: destatisLicence.uri,
289
+ attribution:
290
+ `Quelle: Deutsche Bundesbank, Zeitreihe ${readArg.key}; Daten: Statistisches Bundesamt (Destatis), ` +
291
+ `Verbraucherpreisindex für Deutschland, ${readArg.base}; ${destatisLicence.name} (${destatisLicence.uri}); ${uriArg}`,
292
+ });
@@ -0,0 +1,76 @@
1
+ import type { ICpiServiceAnswer, ICpiValue, TCpiPeriod } from './interfaces.js';
2
+
3
+ /**
4
+ * Why no index value or no result was given:
5
+ * - `invalid_input`: a period, a value, a percentage or an amount that is not one, or a range that ends
6
+ * before it begins;
7
+ * - `before_first_period`: a period before the first one the series holds;
8
+ * - `not_published`: no published value for the period yet: nothing at all, or only an estimate
9
+ * (`estimate`); a calculation given an estimate refuses it the same way;
10
+ * - `base_mismatch`: values of different bases, indices or frequencies, which are never compared;
11
+ * - `series_withdrawn`: the source no longer has the series asked for (HTTP 404), as happens to a
12
+ * series key of an old base after the base year changed;
13
+ * - `busy`: the source refuses for too many requests (HTTP 429);
14
+ * - `unavailable`: the source cannot answer now (HTTP 5xx);
15
+ * - `unreachable`: no answer arrived: the name, the connection or TLS failed;
16
+ * - `timeout`: the answer did not arrive within the client's time;
17
+ * - `aborted`: the caller's signal ended the request;
18
+ * - `unexpected_answer`: an answer this client cannot read: another status, a missing column, a value or
19
+ * a flag it does not know, a period it did not ask for, or more than one series or base.
20
+ */
21
+ export type TCpiErrorCode =
22
+ | 'invalid_input'
23
+ | 'before_first_period'
24
+ | 'not_published'
25
+ | 'base_mismatch'
26
+ | 'series_withdrawn'
27
+ | 'busy'
28
+ | 'unavailable'
29
+ | 'unreachable'
30
+ | 'timeout'
31
+ | 'aborted'
32
+ | 'unexpected_answer';
33
+
34
+ export interface ICpiErrorDetails {
35
+ /** the source asked, where a request was sent */
36
+ service?: 'bundesbank';
37
+ /** the HTTP status of the answer, where one arrived */
38
+ httpStatus?: number;
39
+ /** the answer as it arrived, where one did */
40
+ answer?: ICpiServiceAnswer;
41
+ /** the period the refusal is about */
42
+ period?: TCpiPeriod;
43
+ /** the estimate the source gave for that period, where it gave one (`not_published`) */
44
+ estimate?: ICpiValue;
45
+ }
46
+
47
+ /** The typed error of `@fin.cx/opendata/cpi`. */
48
+ export class CpiError extends Error {
49
+ public readonly code: TCpiErrorCode;
50
+ public readonly service?: ICpiErrorDetails['service'];
51
+ public readonly httpStatus?: number;
52
+ public readonly answer?: ICpiServiceAnswer;
53
+ public readonly period?: TCpiPeriod;
54
+ public readonly estimate?: ICpiValue;
55
+
56
+ constructor(code: TCpiErrorCode, message: string, detailsArg: ICpiErrorDetails = {}, options?: { cause?: unknown }) {
57
+ super(message, options);
58
+ this.name = 'CpiError';
59
+ this.code = code;
60
+ if (detailsArg.service !== undefined) {
61
+ this.service = detailsArg.service;
62
+ }
63
+ if (detailsArg.httpStatus !== undefined) {
64
+ this.httpStatus = detailsArg.httpStatus;
65
+ }
66
+ if (detailsArg.answer !== undefined) {
67
+ this.answer = detailsArg.answer;
68
+ }
69
+ if (detailsArg.period !== undefined) {
70
+ this.period = detailsArg.period;
71
+ }
72
+ if (detailsArg.estimate !== undefined) {
73
+ this.estimate = detailsArg.estimate;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Deutsche Bundesbank's time series API (SDMX REST), dataflow `BBDP1`, where the Bundesbank
3
+ * republishes the Verbraucherpreisindex für Deutschland of the Statistisches Bundesamt ("Quelle:
4
+ * Statistisches Bundesamt, Wiesbaden."). Read on 2026-10-05; it answers without registration.
5
+ */
6
+ export const bundesbankCpiBaseUrl = 'https://api.statistiken.bundesbank.de/rest/data/BBDP1/';
7
+
8
+ /**
9
+ * The series keys of the overall index for Germany, monthly and annual average, unadjusted. The base
10
+ * dimension is left open (`..A`): the answer names the current base (`BBDP1.M.DE.N.VPI.C.A00000.I20.A`,
11
+ * 2020=100), and when the Statistisches Bundesamt changes the base year the Bundesbank withdraws the
12
+ * key of the old one (the `I15` key of 2015=100 answers 404).
13
+ */
14
+ export const bundesbankVpiMonthlyKey = 'M.DE.N.VPI.C.A00000..A';
15
+ export const bundesbankVpiAnnualKey = 'A.DE.N.VPI.C.A00000..A';
16
+
17
+ /** The first month and year of the series (January 1991, the first one the API answers). */
18
+ export const bundesbankVpiFirstMonth = '1991-01';
19
+ export const bundesbankVpiFirstYear = '1991';
20
+
21
+ /** The licence under which the Statistisches Bundesamt publishes its figures. */
22
+ export const destatisLicence = {
23
+ name: 'Datenlizenz Deutschland – Namensnennung – Version 2.0',
24
+ uri: 'https://www.govdata.de/dl-de/by-2-0',
25
+ } as const;
26
+
27
+ /** How long one request may take by default, the answer included. */
28
+ export const defaultCpiTimeoutMs = 30_000;
@@ -0,0 +1,45 @@
1
+ /** A non-negative decimal as integer units of 10^-scale: `'113.5'` is 1135 at scale 1. */
2
+ export interface IScaledDecimal {
3
+ units: bigint;
4
+ scale: number;
5
+ }
6
+
7
+ /** A decimal written with digits and at most one point between them; no sign, no exponent, no comma, no leading zero. */
8
+ const DECIMAL = /^(0|[1-9]\d*)(?:\.(\d+))?$/u;
9
+
10
+ const TEN = 10n;
11
+
12
+ export const powerOfTen = (exponentArg: number): bigint => TEN ** BigInt(exponentArg);
13
+
14
+ export const parseDecimal = (textArg: unknown): IScaledDecimal | undefined => {
15
+ const match = typeof textArg === 'string' ? DECIMAL.exec(textArg) : null;
16
+ if (!match) {
17
+ return undefined;
18
+ }
19
+ const fraction = match[2] ?? '';
20
+ return { units: BigInt(`${match[1]}${fraction}`), scale: fraction.length };
21
+ };
22
+
23
+ /** The units of a decimal at a scale at least its own. */
24
+ export const atScale = (decimalArg: IScaledDecimal, scaleArg: number): bigint =>
25
+ decimalArg.units * powerOfTen(scaleArg - decimalArg.scale);
26
+
27
+ /** numerator / denominator, rounded half away from zero ("kaufmännisch"); the denominator is positive. */
28
+ export const divideRounded = (numeratorArg: bigint, denominatorArg: bigint): bigint => {
29
+ const negative = numeratorArg < 0n;
30
+ const magnitude = negative ? -numeratorArg : numeratorArg;
31
+ let quotient = magnitude / denominatorArg;
32
+ if ((magnitude % denominatorArg) * 2n >= denominatorArg) {
33
+ quotient += 1n;
34
+ }
35
+ return negative ? -quotient : quotient;
36
+ };
37
+
38
+ /** Units of 10^-scale written as a decimal with exactly `scaleArg` digits after the point: `-22` at 1 is `'-2.2'`. */
39
+ export const formatUnits = (unitsArg: bigint, scaleArg: number): string => {
40
+ const negative = unitsArg < 0n;
41
+ const digits = (negative ? -unitsArg : unitsArg).toString().padStart(scaleArg + 1, '0');
42
+ const whole = digits.slice(0, digits.length - scaleArg);
43
+ const fraction = digits.slice(digits.length - scaleArg);
44
+ return `${negative ? '-' : ''}${whole}${scaleArg > 0 ? `.${fraction}` : ''}`;
45
+ };
@@ -0,0 +1,16 @@
1
+ // This entry point stands alone: it imports nothing from the rest of the package and has no
2
+ // dependencies. It keeps nothing: every answer goes to the caller, with the source's answer as it
3
+ // arrived, which the caller keeps as the evidence of an index adjustment.
4
+ export * from './interfaces.js';
5
+ export { CpiError, type ICpiErrorDetails, type TCpiErrorCode } from './classes.cpierror.js';
6
+ export {
7
+ bundesbankCpiBaseUrl,
8
+ bundesbankVpiAnnualKey,
9
+ bundesbankVpiFirstMonth,
10
+ bundesbankVpiFirstYear,
11
+ bundesbankVpiMonthlyKey,
12
+ defaultCpiTimeoutMs,
13
+ destatisLicence,
14
+ } from './constants.js';
15
+ export { cpiChangePercent, cpiChangePoints, cpiFirstReaching, cpiScaleAmount, cpiThresholds } from './indexmath.js';
16
+ export { BundesbankCpiClient } from './classes.bundesbankcpiclient.js';
@@ -0,0 +1,182 @@
1
+ import { CpiError } from './classes.cpierror.js';
2
+ import { atScale, divideRounded, formatUnits, parseDecimal, powerOfTen, type IScaledDecimal } from './decimal.js';
3
+ import type { ICpiReachOptions, ICpiScaleOptions, ICpiThresholds, ICpiValue, TCpiFrequency } from './interfaces.js';
4
+
5
+ // The arithmetic of index clauses as the Statistisches Bundesamt describes it ("Anleitung für die
6
+ // Berechnung von Schwellenwerten und Veränderungsraten für Wertsicherungsklauseln", Stand Januar 2024;
7
+ // "Allgemeine Informationen zum Verbraucherpreisindex"). Every calculation is exact: values are integers
8
+ // of their published digits, and every rounding is half away from zero ("kaufmännisch").
9
+
10
+ const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/u;
11
+ const YEAR = /^\d{4}$/u;
12
+
13
+ /** The frequency a period is written in, or `undefined` for something that is no period. */
14
+ export const frequencyOf = (periodArg: unknown): TCpiFrequency | undefined => {
15
+ if (typeof periodArg !== 'string') {
16
+ return undefined;
17
+ }
18
+ return MONTH.test(periodArg) ? 'monthly' : YEAR.test(periodArg) ? 'annual' : undefined;
19
+ };
20
+
21
+ /** A published value, read as an exact decimal; an estimate or something that is no index value is refused. */
22
+ const publishedValueOf = (valueArg: ICpiValue): IScaledDecimal => {
23
+ if (!frequencyOf(valueArg.period)) {
24
+ throw new CpiError('invalid_input', `${JSON.stringify(valueArg.period)} is no period written YYYY-MM or YYYY.`);
25
+ }
26
+ const decimal = parseDecimal(valueArg.value);
27
+ if (!decimal || decimal.units <= 0n) {
28
+ throw new CpiError('invalid_input', `${JSON.stringify(valueArg.value)} is no index value: a positive decimal written with a point.`, { period: valueArg.period });
29
+ }
30
+ if (valueArg.status !== 'final') {
31
+ throw new CpiError('not_published', `the value for ${valueArg.period} is an estimate, not a published index value.`, { period: valueArg.period, estimate: valueArg });
32
+ }
33
+ return decimal;
34
+ };
35
+
36
+ /** Values of one index, one base and one frequency; anything else is never compared. */
37
+ const requireComparable = (leftArg: ICpiValue, rightArg: ICpiValue): void => {
38
+ if (leftArg.index !== rightArg.index || leftArg.base !== rightArg.base || frequencyOf(leftArg.period) !== frequencyOf(rightArg.period)) {
39
+ throw new CpiError(
40
+ 'base_mismatch',
41
+ `${leftArg.index} ${leftArg.period} (${leftArg.base}) and ${rightArg.index} ${rightArg.period} (${rightArg.base}) are not values of one series and one base; read both on the current base.`,
42
+ );
43
+ }
44
+ };
45
+
46
+ /** The change in percent, in tenths of a percent: ((new / old) × 100) − 100, rounded to one decimal. */
47
+ const changeTenthsOf = (oldArg: ICpiValue, newArg: ICpiValue): bigint => {
48
+ const oldDecimal = publishedValueOf(oldArg);
49
+ const newDecimal = publishedValueOf(newArg);
50
+ requireComparable(oldArg, newArg);
51
+ const scale = Math.max(oldDecimal.scale, newDecimal.scale);
52
+ const oldUnits = atScale(oldDecimal, scale);
53
+ return divideRounded((atScale(newDecimal, scale) - oldUnits) * 1000n, oldUnits);
54
+ };
55
+
56
+ /**
57
+ * The change from one value to another in percent, as the Statistisches Bundesamt computes it:
58
+ * ((new / old) × 100) − 100, rounded half away from zero to one decimal (`'13.0'`, `'-2.2'`).
59
+ * It does not depend on the base, but both values must be read on the same one.
60
+ */
61
+ export const cpiChangePercent = (oldArg: ICpiValue, newArg: ICpiValue): string => formatUnits(changeTenthsOf(oldArg, newArg), 1);
62
+
63
+ /**
64
+ * The change from one value to another in index points (new − old), with the digits of the more
65
+ * precise value (`'12.2'`, `'-1.0'`). Points depend on the base, so they are only given within one.
66
+ */
67
+ export const cpiChangePoints = (oldArg: ICpiValue, newArg: ICpiValue): string => {
68
+ const oldDecimal = publishedValueOf(oldArg);
69
+ const newDecimal = publishedValueOf(newArg);
70
+ requireComparable(oldArg, newArg);
71
+ const scale = Math.max(oldDecimal.scale, newDecimal.scale);
72
+ return formatUnits(atScale(newDecimal, scale) - atScale(oldDecimal, scale), scale);
73
+ };
74
+
75
+ /** The units of the thresholds at the reference value's digits. */
76
+ const thresholdUnitsOf = (referenceArg: ICpiValue, percentArg: string): { upper: bigint; lower: bigint; scale: number } => {
77
+ const reference = publishedValueOf(referenceArg);
78
+ const percent = parseDecimal(percentArg);
79
+ const hundred = percent ? 100n * powerOfTen(percent.scale) : 0n;
80
+ if (!percent || percent.units >= hundred) {
81
+ throw new CpiError('invalid_input', `${JSON.stringify(percentArg)} is no percentage from 0 to below 100, written with digits and a point.`);
82
+ }
83
+ return {
84
+ upper: divideRounded(reference.units * (hundred + percent.units), hundred),
85
+ lower: divideRounded(reference.units * (hundred - percent.units), hundred),
86
+ scale: reference.scale,
87
+ };
88
+ };
89
+
90
+ /**
91
+ * The thresholds of a percent rule: reference × (1 + percent / 100) and reference × (1 − percent / 100),
92
+ * rounded half away from zero to the reference value's digits (99.7 and 5 % give 104.7 and 94.7).
93
+ */
94
+ export const cpiThresholds = (referenceArg: ICpiValue, percentArg: string): ICpiThresholds => {
95
+ const { upper, lower, scale } = thresholdUnitsOf(referenceArg, percentArg);
96
+ return {
97
+ index: referenceArg.index,
98
+ base: referenceArg.base,
99
+ percent: percentArg,
100
+ upper: formatUnits(upper, scale),
101
+ lower: formatUnits(lower, scale),
102
+ };
103
+ };
104
+
105
+ /**
106
+ * The first value after the reference period that reaches a threshold of a percent rule
107
+ * (`cpiThresholds`), or `undefined` while none does. Whether the threshold itself counts (`reached`) or
108
+ * only a value beyond it (`exceeded`), and which direction, is the clause's to say. All values must be
109
+ * published values of the reference value's series and base; their order does not matter, a period
110
+ * given twice is refused.
111
+ */
112
+ export const cpiFirstReaching = (
113
+ valuesArg: readonly ICpiValue[],
114
+ referenceArg: ICpiValue,
115
+ percentArg: string,
116
+ optionsArg: ICpiReachOptions,
117
+ ): ICpiValue | undefined => {
118
+ if (!['up', 'down', 'either'].includes(optionsArg.direction) || !['reached', 'exceeded'].includes(optionsArg.reach)) {
119
+ throw new CpiError('invalid_input', 'the direction is up, down or either, and the reach is reached or exceeded.');
120
+ }
121
+ const thresholds = thresholdUnitsOf(referenceArg, percentArg);
122
+ const periods = new Set<string>();
123
+ const candidates: { value: ICpiValue; decimal: IScaledDecimal }[] = [];
124
+ for (const value of valuesArg) {
125
+ const decimal = publishedValueOf(value);
126
+ requireComparable(referenceArg, value);
127
+ if (periods.has(value.period)) {
128
+ throw new CpiError('invalid_input', `${value.period} is given twice.`, { period: value.period });
129
+ }
130
+ periods.add(value.period);
131
+ if (value.period > referenceArg.period) {
132
+ candidates.push({ value, decimal });
133
+ }
134
+ }
135
+ candidates.sort((leftArg, rightArg) => leftArg.value.period.localeCompare(rightArg.value.period));
136
+ const exceeding = optionsArg.reach === 'exceeded';
137
+ for (const { value, decimal } of candidates) {
138
+ const scale = Math.max(decimal.scale, thresholds.scale);
139
+ const units = atScale(decimal, scale);
140
+ const upper = atScale({ units: thresholds.upper, scale: thresholds.scale }, scale);
141
+ const lower = atScale({ units: thresholds.lower, scale: thresholds.scale }, scale);
142
+ const up = exceeding ? units > upper : units >= upper;
143
+ const down = exceeding ? units < lower : units <= lower;
144
+ if ((optionsArg.direction !== 'down' && up) || (optionsArg.direction !== 'up' && down)) {
145
+ return value;
146
+ }
147
+ }
148
+ return undefined;
149
+ };
150
+
151
+ /**
152
+ * An amount in cents scaled from one index value to another, rounded half away from zero to whole cents:
153
+ * by the index ratio (amount × new / old, the default, as the Statistisches Bundesamt writes it) or by
154
+ * the change in percent rounded to one decimal (`basis: 'rounded_percent'`). Which one applies is the
155
+ * contract's to say. The amount and the result are safe integers; a negative amount (a credit) scales
156
+ * the same way.
157
+ */
158
+ export const cpiScaleAmount = (amountCentsArg: number, oldArg: ICpiValue, newArg: ICpiValue, optionsArg: ICpiScaleOptions = {}): number => {
159
+ const basis = optionsArg.basis ?? 'index_ratio';
160
+ if (!Number.isSafeInteger(amountCentsArg)) {
161
+ throw new CpiError('invalid_input', `${amountCentsArg} is no amount in whole cents.`);
162
+ }
163
+ if (basis !== 'index_ratio' && basis !== 'rounded_percent') {
164
+ throw new CpiError('invalid_input', `${JSON.stringify(basis)} is no basis; it is index_ratio or rounded_percent.`);
165
+ }
166
+ const amount = BigInt(amountCentsArg);
167
+ let scaled: bigint;
168
+ if (basis === 'rounded_percent') {
169
+ scaled = divideRounded(amount * (1000n + changeTenthsOf(oldArg, newArg)), 1000n);
170
+ } else {
171
+ const oldDecimal = publishedValueOf(oldArg);
172
+ const newDecimal = publishedValueOf(newArg);
173
+ requireComparable(oldArg, newArg);
174
+ const scale = Math.max(oldDecimal.scale, newDecimal.scale);
175
+ scaled = divideRounded(amount * atScale(newDecimal, scale), atScale(oldDecimal, scale));
176
+ }
177
+ const result = Number(scaled);
178
+ if (!Number.isSafeInteger(result)) {
179
+ throw new CpiError('invalid_input', `the scaled amount of ${amountCentsArg} cents is beyond the safe integers.`);
180
+ }
181
+ return result;
182
+ };