@fin.cx/opendata 4.3.1 → 4.5.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,230 @@
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
+ /** The reach options as the clause gives them; anything else is refused. */
106
+ const requireReachOptions = (optionsArg: ICpiReachOptions): void => {
107
+ if (!['up', 'down', 'either'].includes(optionsArg.direction) || !['reached', 'exceeded'].includes(optionsArg.reach)) {
108
+ throw new CpiError('invalid_input', 'the direction is up, down or either, and the reach is reached or exceeded.');
109
+ }
110
+ };
111
+
112
+ /**
113
+ * The first value after the reference period at or beyond the upper threshold or at or below the lower
114
+ * one, as the options say. The thresholds are units of 10^-scale; the lower one may be zero or less, which
115
+ * no index value reaches. All values must be published values of the reference value's series and base;
116
+ * their order does not matter, a period given twice is refused.
117
+ */
118
+ const firstReachingUnits = (
119
+ valuesArg: readonly ICpiValue[],
120
+ referenceArg: ICpiValue,
121
+ thresholdsArg: { upper: bigint; lower: bigint; scale: number },
122
+ optionsArg: ICpiReachOptions,
123
+ ): ICpiValue | undefined => {
124
+ const periods = new Set<string>();
125
+ const candidates: { value: ICpiValue; decimal: IScaledDecimal }[] = [];
126
+ for (const value of valuesArg) {
127
+ const decimal = publishedValueOf(value);
128
+ requireComparable(referenceArg, value);
129
+ if (periods.has(value.period)) {
130
+ throw new CpiError('invalid_input', `${value.period} is given twice.`, { period: value.period });
131
+ }
132
+ periods.add(value.period);
133
+ if (value.period > referenceArg.period) {
134
+ candidates.push({ value, decimal });
135
+ }
136
+ }
137
+ candidates.sort((leftArg, rightArg) => leftArg.value.period.localeCompare(rightArg.value.period));
138
+ const exceeding = optionsArg.reach === 'exceeded';
139
+ for (const { value, decimal } of candidates) {
140
+ const scale = Math.max(decimal.scale, thresholdsArg.scale);
141
+ const units = atScale(decimal, scale);
142
+ const upper = thresholdsArg.upper * powerOfTen(scale - thresholdsArg.scale);
143
+ const lower = thresholdsArg.lower * powerOfTen(scale - thresholdsArg.scale);
144
+ const up = exceeding ? units > upper : units >= upper;
145
+ const down = exceeding ? units < lower : units <= lower;
146
+ if ((optionsArg.direction !== 'down' && up) || (optionsArg.direction !== 'up' && down)) {
147
+ return value;
148
+ }
149
+ }
150
+ return undefined;
151
+ };
152
+
153
+ /**
154
+ * The first value after the reference period that reaches a threshold of a percent rule
155
+ * (`cpiThresholds`), or `undefined` while none does. Whether the threshold itself counts (`reached`) or
156
+ * only a value beyond it (`exceeded`), and which direction, is the clause's to say. All values must be
157
+ * published values of the reference value's series and base; their order does not matter, a period
158
+ * given twice is refused.
159
+ */
160
+ export const cpiFirstReaching = (
161
+ valuesArg: readonly ICpiValue[],
162
+ referenceArg: ICpiValue,
163
+ percentArg: string,
164
+ optionsArg: ICpiReachOptions,
165
+ ): ICpiValue | undefined => {
166
+ requireReachOptions(optionsArg);
167
+ return firstReachingUnits(valuesArg, referenceArg, thresholdUnitsOf(referenceArg, percentArg), optionsArg);
168
+ };
169
+
170
+ /**
171
+ * The first value after the reference period whose change from the reference in index points
172
+ * (`cpiChangePoints`) reaches a points rule's threshold, or `undefined` while none does: a value at or
173
+ * above reference + points (`up`), at or below reference − points (`down`), or whichever comes first
174
+ * (`either`). The points are a decimal written with digits and a point, from 0 upward, with any number
175
+ * of digits (`'5'`, `'2.5'`); nothing is rounded, so a threshold with more digits than the values is
176
+ * compared exactly. Whether the threshold itself counts (`reached`) or only a value beyond it
177
+ * (`exceeded`) is the clause's to say. Points depend on the base, so all values must be published
178
+ * values of the reference value's series and base (`base_mismatch` otherwise); their order does not
179
+ * matter, a period given twice is refused.
180
+ */
181
+ export const cpiFirstReachingPoints = (
182
+ valuesArg: readonly ICpiValue[],
183
+ referenceArg: ICpiValue,
184
+ pointsArg: string,
185
+ optionsArg: ICpiReachOptions,
186
+ ): ICpiValue | undefined => {
187
+ requireReachOptions(optionsArg);
188
+ const reference = publishedValueOf(referenceArg);
189
+ const points = parseDecimal(pointsArg);
190
+ if (!points) {
191
+ throw new CpiError('invalid_input', `${JSON.stringify(pointsArg)} is no number of index points from 0 upward, written with digits and a point.`);
192
+ }
193
+ const scale = Math.max(reference.scale, points.scale);
194
+ const referenceUnits = atScale(reference, scale);
195
+ const pointsUnits = atScale(points, scale);
196
+ return firstReachingUnits(valuesArg, referenceArg, { upper: referenceUnits + pointsUnits, lower: referenceUnits - pointsUnits, scale }, optionsArg);
197
+ };
198
+
199
+ /**
200
+ * An amount in cents scaled from one index value to another, rounded half away from zero to whole cents:
201
+ * by the index ratio (amount × new / old, the default, as the Statistisches Bundesamt writes it) or by
202
+ * the change in percent rounded to one decimal (`basis: 'rounded_percent'`). Which one applies is the
203
+ * contract's to say. The amount and the result are safe integers; a negative amount (a credit) scales
204
+ * the same way.
205
+ */
206
+ export const cpiScaleAmount = (amountCentsArg: number, oldArg: ICpiValue, newArg: ICpiValue, optionsArg: ICpiScaleOptions = {}): number => {
207
+ const basis = optionsArg.basis ?? 'index_ratio';
208
+ if (!Number.isSafeInteger(amountCentsArg)) {
209
+ throw new CpiError('invalid_input', `${amountCentsArg} is no amount in whole cents.`);
210
+ }
211
+ if (basis !== 'index_ratio' && basis !== 'rounded_percent') {
212
+ throw new CpiError('invalid_input', `${JSON.stringify(basis)} is no basis; it is index_ratio or rounded_percent.`);
213
+ }
214
+ const amount = BigInt(amountCentsArg);
215
+ let scaled: bigint;
216
+ if (basis === 'rounded_percent') {
217
+ scaled = divideRounded(amount * (1000n + changeTenthsOf(oldArg, newArg)), 1000n);
218
+ } else {
219
+ const oldDecimal = publishedValueOf(oldArg);
220
+ const newDecimal = publishedValueOf(newArg);
221
+ requireComparable(oldArg, newArg);
222
+ const scale = Math.max(oldDecimal.scale, newDecimal.scale);
223
+ scaled = divideRounded(amount * atScale(newDecimal, scale), atScale(oldDecimal, scale));
224
+ }
225
+ const result = Number(scaled);
226
+ if (!Number.isSafeInteger(result)) {
227
+ throw new CpiError('invalid_input', `the scaled amount of ${amountCentsArg} cents is beyond the safe integers.`);
228
+ }
229
+ return result;
230
+ };
@@ -0,0 +1,137 @@
1
+ /** A calendar month, `YYYY-MM`. */
2
+ export type TIsoMonth = string;
3
+
4
+ /** A calendar year, `YYYY`. */
5
+ export type TIsoYear = string;
6
+
7
+ /** The period of an index value: a month (`YYYY-MM`) or a year (`YYYY`, the annual average). */
8
+ export type TCpiPeriod = TIsoMonth | TIsoYear;
9
+
10
+ /** Which price index a value belongs to: `de-vpi`, the Verbraucherpreisindex für Deutschland of the Statistisches Bundesamt. */
11
+ export type TCpiIndex = 'de-vpi';
12
+
13
+ export type TCpiFrequency = 'monthly' | 'annual';
14
+
15
+ /**
16
+ * - `final`: the value as published, final until the next change of the base year;
17
+ * - `estimated`: a value the source marks as an estimate (the Bundesbank's `OBS_STATUS` `E`, for the
18
+ * latest month at times). It is no published index value and is never used in a calculation.
19
+ */
20
+ export type TCpiValueStatus = 'final' | 'estimated';
21
+
22
+ /** One index value as its source states it. */
23
+ export interface ICpiValue {
24
+ index: TCpiIndex;
25
+ period: TCpiPeriod;
26
+ /**
27
+ * The value as a decimal with a point and exactly the digits published, trailing zeros included
28
+ * (`'100.0'`, `'113.5'`). Never a float.
29
+ */
30
+ value: string;
31
+ /** the base the value is stated on, as the source writes it: `'2020=100'` */
32
+ base: string;
33
+ status: TCpiValueStatus;
34
+ }
35
+
36
+ /** A source's answer as it arrived: its HTTP status and its body as text. */
37
+ export interface ICpiServiceAnswer {
38
+ httpStatus: number;
39
+ text: string;
40
+ }
41
+
42
+ /**
43
+ * Where a series comes from and how it must be credited. The figures are those of the Statistisches
44
+ * Bundesamt (Destatis) under the Datenlizenz Deutschland – Namensnennung – Version 2.0.
45
+ */
46
+ export interface ICpiSourceNote {
47
+ source: 'bundesbank';
48
+ /** who delivered the figures: `'Deutsche Bundesbank'` */
49
+ provider: string;
50
+ /** whose figures they are: `'Statistisches Bundesamt (Destatis)'` */
51
+ dataOrigin: string;
52
+ /** the series' title as the answer gives it */
53
+ title: string;
54
+ /** the series key as the answer gives it, its base included: `'BBDP1.M.DE.N.VPI.C.A00000.I20.A'` */
55
+ key: string;
56
+ /** the request the figures were read from */
57
+ uri: string;
58
+ licence: string;
59
+ licenceUri: string;
60
+ /** the source note to show wherever the figures are shown */
61
+ attribution: string;
62
+ }
63
+
64
+ /** A range of index values of one series and one base, as one answer gave them. */
65
+ export interface ICpiSeries {
66
+ index: TCpiIndex;
67
+ frequency: TCpiFrequency;
68
+ /** `'2020=100'` */
69
+ base: string;
70
+ /** `2020` */
71
+ baseYear: number;
72
+ /** the published values, oldest first */
73
+ values: ICpiValue[];
74
+ /** the values the source marks as estimates, oldest first; kept apart so that no calculation takes one */
75
+ estimates: ICpiValue[];
76
+ source: ICpiSourceNote;
77
+ answer: ICpiServiceAnswer;
78
+ }
79
+
80
+ /** One published value with where it comes from and the answer it was read from. */
81
+ export interface ICpiReading extends ICpiValue {
82
+ source: ICpiSourceNote;
83
+ answer: ICpiServiceAnswer;
84
+ }
85
+
86
+ /** The thresholds of a percent rule: the reference value times (1 ± percent / 100), rounded to its digits. */
87
+ export interface ICpiThresholds {
88
+ index: TCpiIndex;
89
+ base: string;
90
+ /** the percentage asked for, as given */
91
+ percent: string;
92
+ upper: string;
93
+ lower: string;
94
+ }
95
+
96
+ export interface ICpiReachOptions {
97
+ /** `up`: the upper threshold, `down`: the lower one, `either`: whichever comes first */
98
+ direction: 'up' | 'down' | 'either';
99
+ /** `reached`: the threshold itself counts; `exceeded`: only a value beyond it counts */
100
+ reach: 'reached' | 'exceeded';
101
+ }
102
+
103
+ export interface ICpiScaleOptions {
104
+ /**
105
+ * - `index_ratio` (default): amount × new index / old index, as the Statistisches Bundesamt writes it;
106
+ * - `rounded_percent`: amount × (100 + the change in percent rounded to one decimal) / 100.
107
+ */
108
+ basis?: 'index_ratio' | 'rounded_percent';
109
+ }
110
+
111
+ /** A `fetch` as the WHATWG standard defines it; injectable for tests and for a fetch that reaches only public addresses. */
112
+ export type TCpiFetch = (input: string, init: RequestInit) => Promise<Response>;
113
+
114
+ export interface ICpiClientOptions {
115
+ /** default: the runtime's `fetch` */
116
+ fetch?: TCpiFetch;
117
+ /** default: the official address (`bundesbankCpiBaseUrl`); a test's own server otherwise */
118
+ baseUrl?: string;
119
+ /** how long one request may take, the answer included; default `defaultCpiTimeoutMs` */
120
+ timeoutMs?: number;
121
+ /** sent as `User-Agent` where given */
122
+ userAgent?: string;
123
+ }
124
+
125
+ export interface ICpiMonthlyInput {
126
+ from: TIsoMonth;
127
+ /** default: whatever the source has published after `from` */
128
+ to?: TIsoMonth;
129
+ signal?: AbortSignal;
130
+ }
131
+
132
+ export interface ICpiAnnualInput {
133
+ from: TIsoYear;
134
+ /** default: whatever the source has published after `from` */
135
+ to?: TIsoYear;
136
+ signal?: AbortSignal;
137
+ }
@@ -0,0 +1,49 @@
1
+ import { CpiError } from './classes.cpierror.js';
2
+ import { defaultCpiTimeoutMs } from './constants.js';
3
+ import type { ICpiClientOptions, ICpiServiceAnswer, TCpiFetch } from './interfaces.js';
4
+
5
+ /** The time a client gives one request: its option, or the default; anything else than a positive whole number of milliseconds is refused. */
6
+ export const timeoutOf = (optionsArg: ICpiClientOptions): number => {
7
+ const timeout = optionsArg.timeoutMs ?? defaultCpiTimeoutMs;
8
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) {
9
+ throw new CpiError('invalid_input', `the time limit is a positive whole number of milliseconds, got ${String(optionsArg.timeoutMs)}`);
10
+ }
11
+ return timeout;
12
+ };
13
+
14
+ /**
15
+ * Sends one `GET` for CSV and reads the answer whole, within the client's time. The caller's signal
16
+ * ends it as `aborted`, the time as `timeout`, and a request that could not be sent or answered as
17
+ * `unreachable`; nothing is retried. The timer ends with the request, answered or not.
18
+ */
19
+ export const getText = async (
20
+ optionsArg: ICpiClientOptions,
21
+ urlArg: string,
22
+ signalArg: AbortSignal | undefined,
23
+ ): Promise<ICpiServiceAnswer> => {
24
+ const fetchFn: TCpiFetch = optionsArg.fetch ?? ((inputArg, initArg) => fetch(inputArg, initArg));
25
+ if (signalArg?.aborted) {
26
+ throw new CpiError('aborted', 'the request was ended by its caller', { service: 'bundesbank' }, { cause: signalArg.reason });
27
+ }
28
+ const timeout = new AbortController();
29
+ const timer = setTimeout(() => timeout.abort(new Error('the client\'s time ran out')), timeoutOf(optionsArg));
30
+ const signal = signalArg ? AbortSignal.any([signalArg, timeout.signal]) : timeout.signal;
31
+ try {
32
+ const response = await fetchFn(urlArg, {
33
+ method: 'GET',
34
+ headers: { accept: 'text/csv', ...(optionsArg.userAgent ? { 'user-agent': optionsArg.userAgent } : {}) },
35
+ signal,
36
+ });
37
+ return { httpStatus: response.status, text: await response.text() };
38
+ } catch (error) {
39
+ if (signalArg?.aborted) {
40
+ throw new CpiError('aborted', 'the request was ended by its caller', { service: 'bundesbank' }, { cause: error });
41
+ }
42
+ if (timeout.signal.aborted) {
43
+ throw new CpiError('timeout', 'the source did not answer in time', { service: 'bundesbank' }, { cause: error });
44
+ }
45
+ throw new CpiError('unreachable', 'the source could not be reached', { service: 'bundesbank' }, { cause: error });
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ };
package/ts/plugins.ts CHANGED
@@ -6,7 +6,6 @@ export {
6
6
  }
7
7
 
8
8
  // @push.rocks scope
9
- import * as qenv from '@push.rocks/qenv';
10
9
  import * as smartpath from '@push.rocks/smartpath';
11
10
  import * as smartpromise from '@push.rocks/smartpromise';
12
11
  import * as smartrequest from '@push.rocks/smartrequest';
@@ -21,7 +20,6 @@ const getErrorMessage = (error: unknown) => {
21
20
 
22
21
  export {
23
22
  getErrorMessage,
24
- qenv,
25
23
  smartpath,
26
24
  smartpromise,
27
25
  smartrequest,