@ducatjs/money 0.0.0 → 0.2.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/LICENSE +21 -0
- package/NOTICE +51 -0
- package/README.md +1034 -3
- package/assets/CREDITS.md +12 -0
- package/data/adyen-fx.json +19 -0
- package/data/adyen.json +171 -0
- package/data/braintree.json +223 -0
- package/data/cash-rounding.json +520 -0
- package/data/checkout.json +230 -0
- package/data/cldr-cash.json +101 -0
- package/data/historic.json +78 -0
- package/data/iso4217-list-one.xml +1956 -0
- package/data/iso4217-list-three.xml +1185 -0
- package/data/klarna.json +95 -0
- package/data/paypal.json +65 -0
- package/data/stripe-fx.json +17 -0
- package/data/stripe.json +285 -0
- package/dist/adyen-fx.d.ts +25 -0
- package/dist/adyen-fx.d.ts.map +1 -0
- package/dist/adyen-fx.js +140 -0
- package/dist/adyen-fx.js.map +1 -0
- package/dist/adyen.d.ts +25 -0
- package/dist/adyen.d.ts.map +1 -0
- package/dist/adyen.js +34 -0
- package/dist/adyen.js.map +1 -0
- package/dist/allocate.d.ts +72 -0
- package/dist/allocate.d.ts.map +1 -0
- package/dist/allocate.js +314 -0
- package/dist/allocate.js.map +1 -0
- package/dist/bag.d.ts +58 -0
- package/dist/bag.d.ts.map +1 -0
- package/dist/bag.js +122 -0
- package/dist/bag.js.map +1 -0
- package/dist/boc.d.ts +31 -0
- package/dist/boc.d.ts.map +1 -0
- package/dist/boc.js +114 -0
- package/dist/boc.js.map +1 -0
- package/dist/boe.d.ts +33 -0
- package/dist/boe.d.ts.map +1 -0
- package/dist/boe.js +114 -0
- package/dist/boe.js.map +1 -0
- package/dist/braintree.d.ts +34 -0
- package/dist/braintree.d.ts.map +1 -0
- package/dist/braintree.js +45 -0
- package/dist/braintree.js.map +1 -0
- package/dist/cash.d.ts +64 -0
- package/dist/cash.d.ts.map +1 -0
- package/dist/cash.js +134 -0
- package/dist/cash.js.map +1 -0
- package/dist/checkout.d.ts +42 -0
- package/dist/checkout.d.ts.map +1 -0
- package/dist/checkout.js +52 -0
- package/dist/checkout.js.map +1 -0
- package/dist/convert.d.ts +52 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +65 -0
- package/dist/convert.js.map +1 -0
- package/dist/currency.d.ts +22 -0
- package/dist/currency.d.ts.map +1 -0
- package/dist/currency.js +38 -0
- package/dist/currency.js.map +1 -0
- package/dist/decimal.d.ts +52 -0
- package/dist/decimal.d.ts.map +1 -0
- package/dist/decimal.js +251 -0
- package/dist/decimal.js.map +1 -0
- package/dist/drizzle/mysql.d.ts +105 -0
- package/dist/drizzle/mysql.d.ts.map +1 -0
- package/dist/drizzle/mysql.js +60 -0
- package/dist/drizzle/mysql.js.map +1 -0
- package/dist/drizzle/pg.d.ts +103 -0
- package/dist/drizzle/pg.d.ts.map +1 -0
- package/dist/drizzle/pg.js +60 -0
- package/dist/drizzle/pg.js.map +1 -0
- package/dist/drizzle/sqlite.d.ts +102 -0
- package/dist/drizzle/sqlite.d.ts.map +1 -0
- package/dist/drizzle/sqlite.js +59 -0
- package/dist/drizzle/sqlite.js.map +1 -0
- package/dist/ecb.d.ts +52 -0
- package/dist/ecb.d.ts.map +1 -0
- package/dist/ecb.js +285 -0
- package/dist/ecb.js.map +1 -0
- package/dist/exact.d.ts +62 -0
- package/dist/exact.d.ts.map +1 -0
- package/dist/exact.js +147 -0
- package/dist/exact.js.map +1 -0
- package/dist/fed.d.ts +35 -0
- package/dist/fed.d.ts.map +1 -0
- package/dist/fed.js +134 -0
- package/dist/fed.js.map +1 -0
- package/dist/fetch.d.ts +103 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +222 -0
- package/dist/fetch.js.map +1 -0
- package/dist/fixed.d.ts +121 -0
- package/dist/fixed.d.ts.map +1 -0
- package/dist/fixed.js +420 -0
- package/dist/fixed.js.map +1 -0
- package/dist/fn.d.ts +34 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +37 -0
- package/dist/fn.js.map +1 -0
- package/dist/format.d.ts +13 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +90 -0
- package/dist/format.js.map +1 -0
- package/dist/generated/adyen.d.ts +3 -0
- package/dist/generated/adyen.d.ts.map +1 -0
- package/dist/generated/adyen.js +144 -0
- package/dist/generated/adyen.js.map +1 -0
- package/dist/generated/braintree.d.ts +3 -0
- package/dist/generated/braintree.d.ts.map +1 -0
- package/dist/generated/braintree.js +140 -0
- package/dist/generated/braintree.js.map +1 -0
- package/dist/generated/cash.d.ts +14 -0
- package/dist/generated/cash.d.ts.map +1 -0
- package/dist/generated/cash.js +465 -0
- package/dist/generated/cash.js.map +1 -0
- package/dist/generated/checkout.d.ts +3 -0
- package/dist/generated/checkout.d.ts.map +1 -0
- package/dist/generated/checkout.js +151 -0
- package/dist/generated/checkout.js.map +1 -0
- package/dist/generated/historic.d.ts +337 -0
- package/dist/generated/historic.d.ts.map +1 -0
- package/dist/generated/historic.js +66 -0
- package/dist/generated/historic.js.map +1 -0
- package/dist/generated/iso4217.d.ts +174 -0
- package/dist/generated/iso4217.d.ts.map +1 -0
- package/dist/generated/iso4217.js +175 -0
- package/dist/generated/iso4217.js.map +1 -0
- package/dist/generated/kernel.d.ts +17 -0
- package/dist/generated/kernel.d.ts.map +1 -0
- package/dist/generated/kernel.js +208 -0
- package/dist/generated/kernel.js.map +1 -0
- package/dist/generated/klarna.d.ts +3 -0
- package/dist/generated/klarna.d.ts.map +1 -0
- package/dist/generated/klarna.js +21 -0
- package/dist/generated/klarna.js.map +1 -0
- package/dist/generated/paypal.d.ts +3 -0
- package/dist/generated/paypal.d.ts.map +1 -0
- package/dist/generated/paypal.js +31 -0
- package/dist/generated/paypal.js.map +1 -0
- package/dist/generated/stripe.d.ts +8 -0
- package/dist/generated/stripe.d.ts.map +1 -0
- package/dist/generated/stripe.js +150 -0
- package/dist/generated/stripe.js.map +1 -0
- package/dist/historic.d.ts +40 -0
- package/dist/historic.d.ts.map +1 -0
- package/dist/historic.js +43 -0
- package/dist/historic.js.map +1 -0
- package/dist/history.d.ts +96 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/history.js +213 -0
- package/dist/history.js.map +1 -0
- package/dist/hmrc.d.ts +28 -0
- package/dist/hmrc.d.ts.map +1 -0
- package/dist/hmrc.js +108 -0
- package/dist/hmrc.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/klarna.d.ts +31 -0
- package/dist/klarna.d.ts.map +1 -0
- package/dist/klarna.js +38 -0
- package/dist/klarna.js.map +1 -0
- package/dist/kysely.d.ts +120 -0
- package/dist/kysely.d.ts.map +1 -0
- package/dist/kysely.js +309 -0
- package/dist/kysely.js.map +1 -0
- package/dist/money.d.ts +302 -0
- package/dist/money.d.ts.map +1 -0
- package/dist/money.js +703 -0
- package/dist/money.js.map +1 -0
- package/dist/parse.d.ts +55 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +419 -0
- package/dist/parse.js.map +1 -0
- package/dist/paypal.d.ts +41 -0
- package/dist/paypal.d.ts.map +1 -0
- package/dist/paypal.js +58 -0
- package/dist/paypal.js.map +1 -0
- package/dist/pg.d.ts +100 -0
- package/dist/pg.d.ts.map +1 -0
- package/dist/pg.js +343 -0
- package/dist/pg.js.map +1 -0
- package/dist/plugins/adyen.d.ts +15 -0
- package/dist/plugins/adyen.d.ts.map +1 -0
- package/dist/plugins/adyen.js +10 -0
- package/dist/plugins/adyen.js.map +1 -0
- package/dist/plugins/all.d.ts +29 -0
- package/dist/plugins/all.d.ts.map +1 -0
- package/dist/plugins/all.js +30 -0
- package/dist/plugins/all.js.map +1 -0
- package/dist/plugins/allocate.d.ts +20 -0
- package/dist/plugins/allocate.d.ts.map +1 -0
- package/dist/plugins/allocate.js +26 -0
- package/dist/plugins/allocate.js.map +1 -0
- package/dist/plugins/braintree.d.ts +12 -0
- package/dist/plugins/braintree.d.ts.map +1 -0
- package/dist/plugins/braintree.js +12 -0
- package/dist/plugins/braintree.js.map +1 -0
- package/dist/plugins/cash.d.ts +19 -0
- package/dist/plugins/cash.d.ts.map +1 -0
- package/dist/plugins/cash.js +16 -0
- package/dist/plugins/cash.js.map +1 -0
- package/dist/plugins/checkout.d.ts +21 -0
- package/dist/plugins/checkout.d.ts.map +1 -0
- package/dist/plugins/checkout.js +16 -0
- package/dist/plugins/checkout.js.map +1 -0
- package/dist/plugins/convert.d.ts +19 -0
- package/dist/plugins/convert.d.ts.map +1 -0
- package/dist/plugins/convert.js +13 -0
- package/dist/plugins/convert.js.map +1 -0
- package/dist/plugins/exact.d.ts +15 -0
- package/dist/plugins/exact.d.ts.map +1 -0
- package/dist/plugins/exact.js +13 -0
- package/dist/plugins/exact.js.map +1 -0
- package/dist/plugins/fixed.d.ts +17 -0
- package/dist/plugins/fixed.d.ts.map +1 -0
- package/dist/plugins/fixed.js +13 -0
- package/dist/plugins/fixed.js.map +1 -0
- package/dist/plugins/format.d.ts +9 -0
- package/dist/plugins/format.d.ts.map +1 -0
- package/dist/plugins/format.js +16 -0
- package/dist/plugins/format.js.map +1 -0
- package/dist/plugins/klarna.d.ts +10 -0
- package/dist/plugins/klarna.d.ts.map +1 -0
- package/dist/plugins/klarna.js +10 -0
- package/dist/plugins/klarna.js.map +1 -0
- package/dist/plugins/paypal.d.ts +14 -0
- package/dist/plugins/paypal.d.ts.map +1 -0
- package/dist/plugins/paypal.js +16 -0
- package/dist/plugins/paypal.js.map +1 -0
- package/dist/plugins/prisma.d.ts +8 -0
- package/dist/plugins/prisma.d.ts.map +1 -0
- package/dist/plugins/prisma.js +13 -0
- package/dist/plugins/prisma.js.map +1 -0
- package/dist/plugins/stripe.d.ts +14 -0
- package/dist/plugins/stripe.d.ts.map +1 -0
- package/dist/plugins/stripe.js +16 -0
- package/dist/plugins/stripe.js.map +1 -0
- package/dist/plugins/tax.d.ts +20 -0
- package/dist/plugins/tax.d.ts.map +1 -0
- package/dist/plugins/tax.js +18 -0
- package/dist/plugins/tax.js.map +1 -0
- package/dist/policy.d.ts +34 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +90 -0
- package/dist/policy.js.map +1 -0
- package/dist/prisma.d.ts +77 -0
- package/dist/prisma.d.ts.map +1 -0
- package/dist/prisma.js +76 -0
- package/dist/prisma.js.map +1 -0
- package/dist/processor.d.ts +28 -0
- package/dist/processor.d.ts.map +1 -0
- package/dist/processor.js +118 -0
- package/dist/processor.js.map +1 -0
- package/dist/provider.d.ts +103 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +280 -0
- package/dist/provider.js.map +1 -0
- package/dist/rate-text.d.ts +38 -0
- package/dist/rate-text.d.ts.map +1 -0
- package/dist/rate-text.js +143 -0
- package/dist/rate-text.js.map +1 -0
- package/dist/rate.d.ts +175 -0
- package/dist/rate.d.ts.map +1 -0
- package/dist/rate.js +371 -0
- package/dist/rate.js.map +1 -0
- package/dist/rounding-error.d.ts +9 -0
- package/dist/rounding-error.d.ts.map +1 -0
- package/dist/rounding-error.js +9 -0
- package/dist/rounding-error.js.map +1 -0
- package/dist/rounding.d.ts +50 -0
- package/dist/rounding.d.ts.map +1 -0
- package/dist/rounding.js +87 -0
- package/dist/rounding.js.map +1 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +54 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +131 -0
- package/dist/sql.js.map +1 -0
- package/dist/stripe-fx.d.ts +22 -0
- package/dist/stripe-fx.d.ts.map +1 -0
- package/dist/stripe-fx.js +30 -0
- package/dist/stripe-fx.js.map +1 -0
- package/dist/stripe.d.ts +36 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +39 -0
- package/dist/stripe.js.map +1 -0
- package/dist/tax.d.ts +33 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +55 -0
- package/dist/tax.js.map +1 -0
- package/dist/testing/arbitraries.d.ts +43 -0
- package/dist/testing/arbitraries.d.ts.map +1 -0
- package/dist/testing/arbitraries.js +98 -0
- package/dist/testing/arbitraries.js.map +1 -0
- package/dist/testing/index.d.ts +67 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +159 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/jest.d.ts +2 -0
- package/dist/testing/jest.d.ts.map +1 -0
- package/dist/testing/jest.js +15 -0
- package/dist/testing/jest.js.map +1 -0
- package/dist/testing/vitest.d.ts +7 -0
- package/dist/testing/vitest.d.ts.map +1 -0
- package/dist/testing/vitest.js +12 -0
- package/dist/testing/vitest.js.map +1 -0
- package/dist/valibot.d.ts +33 -0
- package/dist/valibot.d.ts.map +1 -0
- package/dist/valibot.js +61 -0
- package/dist/valibot.js.map +1 -0
- package/dist/zod.d.ts +36 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +58 -0
- package/dist/zod.js.map +1 -0
- package/package.json +280 -5
- package/src/adyen-fx.ts +175 -0
- package/src/adyen.ts +46 -0
- package/src/allocate.ts +393 -0
- package/src/bag.ts +170 -0
- package/src/boc.ts +136 -0
- package/src/boe.ts +131 -0
- package/src/braintree.ts +71 -0
- package/src/cash.ts +183 -0
- package/src/checkout.ts +77 -0
- package/src/convert.ts +177 -0
- package/src/currency.ts +61 -0
- package/src/decimal.ts +283 -0
- package/src/drizzle/mysql.ts +78 -0
- package/src/drizzle/pg.ts +78 -0
- package/src/drizzle/sqlite.ts +70 -0
- package/src/ecb.ts +328 -0
- package/src/exact.ts +182 -0
- package/src/fed.ts +146 -0
- package/src/fetch.ts +306 -0
- package/src/fixed.ts +558 -0
- package/src/fn.ts +94 -0
- package/src/format.ts +106 -0
- package/src/generated/adyen.ts +144 -0
- package/src/generated/braintree.ts +140 -0
- package/src/generated/cash.ts +478 -0
- package/src/generated/checkout.ts +151 -0
- package/src/generated/historic.ts +67 -0
- package/src/generated/iso4217.ts +179 -0
- package/src/generated/kernel.ts +230 -0
- package/src/generated/klarna.ts +21 -0
- package/src/generated/paypal.ts +31 -0
- package/src/generated/stripe.ts +150 -0
- package/src/historic.ts +77 -0
- package/src/history.ts +333 -0
- package/src/hmrc.ts +125 -0
- package/src/index.ts +41 -0
- package/src/klarna.ts +54 -0
- package/src/kysely.ts +588 -0
- package/src/money.ts +979 -0
- package/src/parse.ts +511 -0
- package/src/paypal.ts +93 -0
- package/src/pg.ts +410 -0
- package/src/plugins/adyen.ts +25 -0
- package/src/plugins/all.ts +57 -0
- package/src/plugins/allocate.ts +53 -0
- package/src/plugins/braintree.ts +23 -0
- package/src/plugins/cash.ts +33 -0
- package/src/plugins/checkout.ts +32 -0
- package/src/plugins/convert.ts +49 -0
- package/src/plugins/exact.ts +23 -0
- package/src/plugins/fixed.ts +28 -0
- package/src/plugins/format.ts +28 -0
- package/src/plugins/klarna.ts +21 -0
- package/src/plugins/paypal.ts +29 -0
- package/src/plugins/prisma.ts +24 -0
- package/src/plugins/stripe.ts +29 -0
- package/src/plugins/tax.ts +40 -0
- package/src/policy.ts +124 -0
- package/src/prisma.ts +199 -0
- package/src/processor.ts +181 -0
- package/src/provider.ts +389 -0
- package/src/rate-text.ts +151 -0
- package/src/rate.ts +546 -0
- package/src/rounding-error.ts +8 -0
- package/src/rounding.ts +122 -0
- package/src/schema.ts +119 -0
- package/src/sql.ts +178 -0
- package/src/stripe-fx.ts +49 -0
- package/src/stripe.ts +69 -0
- package/src/tax.ts +102 -0
- package/src/testing/arbitraries.ts +134 -0
- package/src/testing/index.ts +247 -0
- package/src/testing/jest.ts +18 -0
- package/src/testing/vitest.ts +18 -0
- package/src/valibot.ts +87 -0
- package/src/zod.ts +78 -0
package/src/ecb.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// Offline parsers for the ECB euro foreign exchange reference rates (eurofxref). No I/O: fetch or
|
|
2
|
+
// read the files yourself, or use ducat/convert/fetch. Rates are kept exactly as published.
|
|
3
|
+
import { rateTable, type RateTable } from './provider.js';
|
|
4
|
+
import { addDayRate, csvRows, newestFirst, readRateCell, withDecimals } from './rate-text.js';
|
|
5
|
+
|
|
6
|
+
const MONTHS = [
|
|
7
|
+
'january',
|
|
8
|
+
'february',
|
|
9
|
+
'march',
|
|
10
|
+
'april',
|
|
11
|
+
'may',
|
|
12
|
+
'june',
|
|
13
|
+
'july',
|
|
14
|
+
'august',
|
|
15
|
+
'september',
|
|
16
|
+
'october',
|
|
17
|
+
'november',
|
|
18
|
+
'december',
|
|
19
|
+
];
|
|
20
|
+
const DAY_CUBE = /<Cube\s+time=["'](\d{4}-\d\d-\d\d)["']\s*>([\s\S]*?)<\/Cube>/g;
|
|
21
|
+
// One <Cube .../> element, and its attributes in any order and with any quoting or spacing.
|
|
22
|
+
const INNER_CUBE = /<Cube\b([^>]*?)\/?>/g;
|
|
23
|
+
const ATTRIBUTE = /([A-Za-z]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
24
|
+
const RATE_TEXT = /^\d+(?:\.\d+)?$/;
|
|
25
|
+
const ISO_DAY = /^(\d{4})-(\d\d)-(\d\d)$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The ECB's rates are typed 'reference', not 'mid': they are set once a day, around 14:10 CET, from
|
|
29
|
+
* a regular concertation between central banks, and published for information only. They are not a
|
|
30
|
+
* market mid-rate that anyone deals at.
|
|
31
|
+
*/
|
|
32
|
+
function ecbTable(asOf: string, rates: Record<string, string | null>): RateTable {
|
|
33
|
+
return rateTable({ base: 'EUR', asOf, source: 'ECB', type: 'reference', rates });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function notAnEcbFile(): never {
|
|
37
|
+
throw new SyntaxError('Not an ECB reference rate file');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function withoutByteOrderMark(text: string): string {
|
|
41
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The currency → rate attributes of the <Cube> elements in one day's cube. */
|
|
45
|
+
function ratesOfDay(body: string): Record<string, string> {
|
|
46
|
+
const rates: Record<string, string> = {};
|
|
47
|
+
for (const [, attributeText] of body.matchAll(INNER_CUBE)) {
|
|
48
|
+
const attributes: Record<string, string> = {};
|
|
49
|
+
for (const [, name, doubleQuoted, singleQuoted] of attributeText!.matchAll(ATTRIBUTE)) {
|
|
50
|
+
attributes[name!] = doubleQuoted ?? singleQuoted!;
|
|
51
|
+
}
|
|
52
|
+
const currency = attributes['currency'];
|
|
53
|
+
const rate = attributes['rate'];
|
|
54
|
+
if (currency === undefined && rate === undefined) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Fail rather than silently drop a currency: a missing rate would look like "not quoted".
|
|
58
|
+
if (
|
|
59
|
+
currency === undefined ||
|
|
60
|
+
!/^[A-Z]{3}$/.test(currency) ||
|
|
61
|
+
rate === undefined ||
|
|
62
|
+
!RATE_TEXT.test(rate)
|
|
63
|
+
) {
|
|
64
|
+
throw new SyntaxError(`Malformed ECB rate entry <Cube${attributeText}>`);
|
|
65
|
+
}
|
|
66
|
+
rates[currency] = rate;
|
|
67
|
+
}
|
|
68
|
+
return rates;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parses eurofxref-daily.xml, eurofxref-hist-90d.xml or eurofxref-hist.xml into one EUR-based table
|
|
73
|
+
* per day, newest first. Each rate is EUR → currency, with `source: 'ECB'`, `type: 'reference'` and
|
|
74
|
+
* `asOf` the publication day. A day without any rates is left out, so a lookback goes on to the day
|
|
75
|
+
* before. A rate entry that can't be read throws SyntaxError.
|
|
76
|
+
*
|
|
77
|
+
* const [latest] = parseEcbXml(await readFile('eurofxref-daily.xml', 'utf8'));
|
|
78
|
+
*/
|
|
79
|
+
export function parseEcbXml(xml: string): RateTable[] {
|
|
80
|
+
let days = 0;
|
|
81
|
+
const tables: RateTable[] = [];
|
|
82
|
+
for (const [, asOf, body] of withoutByteOrderMark(xml).matchAll(DAY_CUBE)) {
|
|
83
|
+
days++;
|
|
84
|
+
const day = isoDay(asOf!);
|
|
85
|
+
const rates = ratesOfDay(body!);
|
|
86
|
+
if (Object.keys(rates).length > 0) {
|
|
87
|
+
tables.push(ecbTable(day, rates));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (days === 0) {
|
|
92
|
+
notAnEcbFile();
|
|
93
|
+
}
|
|
94
|
+
return newestFirst(tables);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The ISO date of year-month-day, or a SyntaxError when that day doesn't exist (32 November). */
|
|
98
|
+
function calendarDay(year: number, month: number, day: number): string {
|
|
99
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
100
|
+
if (
|
|
101
|
+
date.getUTCFullYear() !== year ||
|
|
102
|
+
date.getUTCMonth() !== month - 1 ||
|
|
103
|
+
date.getUTCDate() !== day
|
|
104
|
+
) {
|
|
105
|
+
throw new SyntaxError(`Not a calendar day: ${year}-${month}-${day}`);
|
|
106
|
+
}
|
|
107
|
+
return date.toISOString().slice(0, 10);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** '2026-11-06', or '06 November 2026' as in the daily CSV, as an ISO date. */
|
|
111
|
+
function isoDay(text: string): string {
|
|
112
|
+
const iso = ISO_DAY.exec(text);
|
|
113
|
+
if (iso !== null) {
|
|
114
|
+
return calendarDay(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
|
115
|
+
}
|
|
116
|
+
const match = /^(\d{1,2}) ([A-Za-z]+) (\d{4})$/.exec(text);
|
|
117
|
+
if (match === null) {
|
|
118
|
+
notAnEcbFile();
|
|
119
|
+
}
|
|
120
|
+
const [, day, monthName, year] = match;
|
|
121
|
+
const month = MONTHS.indexOf(monthName!.toLowerCase()) + 1;
|
|
122
|
+
if (month === 0) {
|
|
123
|
+
notAnEcbFile();
|
|
124
|
+
}
|
|
125
|
+
return calendarDay(Number(year), month, Number(day));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parses eurofxref.csv or eurofxref-hist.csv (from the ECB's zips) the way parseEcbXml does.
|
|
130
|
+
* 'N/A' and blank cells (currencies not quoted that day) are listed in the table's `missing`, and a
|
|
131
|
+
* day with no rates at all is left out, so a lookback goes on to the day before.
|
|
132
|
+
*/
|
|
133
|
+
export function parseEcbCsv(csv: string): RateTable[] {
|
|
134
|
+
const lines = withoutByteOrderMark(csv)
|
|
135
|
+
.split(/\r?\n/)
|
|
136
|
+
.filter((line) => line.trim() !== '');
|
|
137
|
+
const columns = (lines[0] ?? '').split(',').map((cell) => cell.trim());
|
|
138
|
+
if (columns[0] !== 'Date') {
|
|
139
|
+
notAnEcbFile();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tables: RateTable[] = [];
|
|
143
|
+
for (const line of lines.slice(1)) {
|
|
144
|
+
const cells = line.split(',').map((cell) => cell.trim());
|
|
145
|
+
const day = isoDay(cells[0]!);
|
|
146
|
+
const rates: Record<string, string | null> = {};
|
|
147
|
+
let quoted = 0;
|
|
148
|
+
for (let i = 1; i < columns.length; i++) {
|
|
149
|
+
const currency = columns[i];
|
|
150
|
+
if (!currency) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const rate = readRateCell(cells[i] ?? '', `${currency} on ${day}`);
|
|
154
|
+
rates[currency] = rate;
|
|
155
|
+
if (rate !== null) {
|
|
156
|
+
quoted++;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (quoted > 0) {
|
|
160
|
+
tables.push(ecbTable(day, rates));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return newestFirst(tables);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Series whose DECIMALS attribute says more than the ECB has ever published for them. Evidence:
|
|
167
|
+
// eurofxref-daily.xml publishes THB to 3 decimals (37.459 on 2026-11-06) while DECIMALS says 4,
|
|
168
|
+
// and eurofxref-hist.xml (1999 to 2026) never has more than 4 for LTL, whose DECIMALS says 5.
|
|
169
|
+
// Padding to the attribute would invent a digit. A cap only limits padding: a value published
|
|
170
|
+
// with more digits keeps them all (withDecimals never drops digits).
|
|
171
|
+
const PUBLISHED_DECIMALS: Readonly<Record<string, number>> = { THB: 3, LTL: 4 };
|
|
172
|
+
|
|
173
|
+
/** Options for parseEcbSdmxCsv. */
|
|
174
|
+
export interface EcbSdmxOptions {
|
|
175
|
+
/**
|
|
176
|
+
* The decimals each currency is published with, for a file without the DECIMALS column (as
|
|
177
|
+
* fetched with `detail=dataonly`). A DECIMALS column in the file takes precedence.
|
|
178
|
+
*/
|
|
179
|
+
readonly decimals?: Readonly<Record<string, number>>;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** A DECIMALS cell as a number of decimals, or SyntaxError. */
|
|
183
|
+
function readDecimals(text: string, where: string): number {
|
|
184
|
+
if (!/^\d{1,2}$/.test(text) || Number(text) > 30) {
|
|
185
|
+
throw new SyntaxError(`Malformed DECIMALS ${JSON.stringify(text)} (${where})`);
|
|
186
|
+
}
|
|
187
|
+
return Number(text);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A series' stated decimals, capped at what the ECB actually publishes for it. */
|
|
191
|
+
function statedDecimals(currency: string, text: string): number {
|
|
192
|
+
const stated = readDecimals(text, currency);
|
|
193
|
+
const published = PUBLISHED_DECIMALS[currency];
|
|
194
|
+
if (published !== undefined && published < stated) {
|
|
195
|
+
return published;
|
|
196
|
+
}
|
|
197
|
+
return stated;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The records of an SDMX-CSV file after its header, each with as many cells as the header. */
|
|
201
|
+
function sdmxRecords(csv: string): { header: string[]; rows: string[][] } {
|
|
202
|
+
const [header, ...rows] = csvRows(withoutByteOrderMark(csv));
|
|
203
|
+
if (header === undefined) {
|
|
204
|
+
throw new SyntaxError('Not an ECB SDMX-CSV file: it is empty');
|
|
205
|
+
}
|
|
206
|
+
for (const cells of rows) {
|
|
207
|
+
if (cells.length !== header.length) {
|
|
208
|
+
throw new SyntaxError(
|
|
209
|
+
`Malformed ECB SDMX-CSV row: ${cells.length} cells, the header has ${header.length}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return { header, rows };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The columns of an SDMX-CSV file, by name, or SyntaxError when a required one is missing. */
|
|
217
|
+
function sdmxColumns(header: readonly string[], required: readonly string[]): Map<string, number> {
|
|
218
|
+
const columns = new Map(header.map((name, i) => [name.trim(), i] as const));
|
|
219
|
+
for (const name of required) {
|
|
220
|
+
if (!columns.has(name)) {
|
|
221
|
+
throw new SyntaxError(`Not an ECB SDMX-CSV file: no ${name} column`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return columns;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The DECIMALS of each series in an ECB SDMX-CSV file: the data file itself with full detail, or
|
|
229
|
+
* the series file the API returns with `detail=nodata`.
|
|
230
|
+
*/
|
|
231
|
+
export function parseEcbSdmxDecimals(csv: string): Record<string, number> {
|
|
232
|
+
const { header, rows } = sdmxRecords(csv);
|
|
233
|
+
const columns = sdmxColumns(header, ['CURRENCY', 'DECIMALS']);
|
|
234
|
+
const decimals: Record<string, number> = {};
|
|
235
|
+
for (const cells of rows) {
|
|
236
|
+
const currency = cells[columns.get('CURRENCY')!]!.trim();
|
|
237
|
+
decimals[currency] = statedDecimals(currency, cells[columns.get('DECIMALS')!]!.trim());
|
|
238
|
+
}
|
|
239
|
+
return decimals;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The value of an optional column, or undefined when the file doesn't have it. */
|
|
243
|
+
function optionalCell(cells: readonly string[], columns: Map<string, number>, name: string) {
|
|
244
|
+
const index = columns.get(name);
|
|
245
|
+
if (index === undefined) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
return cells[index]?.trim();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Parses the ECB Data Portal's SDMX-CSV (`format=csvdata`) for the daily euro reference rates
|
|
253
|
+
* (dataflow EXR, key `D.<currencies>.EUR.SP00.A`) into one EUR-based table per day, newest first,
|
|
254
|
+
* like parseEcbXml. Any date range back to 1999 comes in one file.
|
|
255
|
+
*
|
|
256
|
+
* The API strips trailing zeros ('136.8' for the published 136.80), so the published digits are
|
|
257
|
+
* rebuilt from each series' DECIMALS attribute: in the file with full detail, or from `decimals`
|
|
258
|
+
* (see parseEcbSdmxDecimals) for a `detail=dataonly` file. Two series overstate it (THB and LTL),
|
|
259
|
+
* and are padded only to the decimals the ECB actually published. Without DECIMALS, the digits
|
|
260
|
+
* stay as the file gives them; the values are exact either way. An empty or `NaN` value is a missing value.
|
|
261
|
+
* A row for another frequency, rate type or base currency, or with a UNIT_MULT other than 0,
|
|
262
|
+
* throws SyntaxError rather than being read as a daily rate. Discontinued series simply end, so
|
|
263
|
+
* the tables of their last days include them.
|
|
264
|
+
*
|
|
265
|
+
* No `availableAt` is set, as with parseEcbXml: the API gives no publication time, and the ECB
|
|
266
|
+
* publishes at about 16:00 CET. So a backtest that asks for 10:00 CET on a day still gets that
|
|
267
|
+
* day's rate; ask for the day before if that matters.
|
|
268
|
+
*
|
|
269
|
+
* const tables = parseEcbSdmxCsv(await (await fetch(url)).text());
|
|
270
|
+
* const history = rateHistory(tables); // from @ducatjs/money/convert/history
|
|
271
|
+
*/
|
|
272
|
+
export function parseEcbSdmxCsv(csv: string, options?: EcbSdmxOptions): RateTable[] {
|
|
273
|
+
const { header, rows } = sdmxRecords(csv);
|
|
274
|
+
const columns = sdmxColumns(header, ['CURRENCY', 'CURRENCY_DENOM', 'TIME_PERIOD', 'OBS_VALUE']);
|
|
275
|
+
const at = (cells: readonly string[], name: string): string =>
|
|
276
|
+
cells[columns.get(name)!]?.trim() ?? '';
|
|
277
|
+
|
|
278
|
+
const days = new Map<string, Record<string, string | null>>();
|
|
279
|
+
for (const cells of rows) {
|
|
280
|
+
const currency = at(cells, 'CURRENCY');
|
|
281
|
+
const day = at(cells, 'TIME_PERIOD');
|
|
282
|
+
const where = `${currency} on ${day}`;
|
|
283
|
+
const unexpected =
|
|
284
|
+
at(cells, 'CURRENCY_DENOM') !== 'EUR' ||
|
|
285
|
+
(optionalCell(cells, columns, 'FREQ') ?? 'D') !== 'D' ||
|
|
286
|
+
(optionalCell(cells, columns, 'EXR_TYPE') ?? 'SP00') !== 'SP00' ||
|
|
287
|
+
(optionalCell(cells, columns, 'EXR_SUFFIX') ?? 'A') !== 'A';
|
|
288
|
+
if (unexpected) {
|
|
289
|
+
throw new SyntaxError(`Not a daily EUR reference rate: ${cells.join(',')}`);
|
|
290
|
+
}
|
|
291
|
+
if (!/^[A-Z]{3}$/.test(currency) || !ISO_DAY.test(day)) {
|
|
292
|
+
throw new SyntaxError(`Malformed ECB SDMX-CSV row: ${cells.join(',')}`);
|
|
293
|
+
}
|
|
294
|
+
const multiplier = optionalCell(cells, columns, 'UNIT_MULT');
|
|
295
|
+
if (multiplier !== undefined && multiplier !== '' && multiplier !== '0') {
|
|
296
|
+
throw new SyntaxError(`Unsupported UNIT_MULT ${multiplier} (${where})`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const value = at(cells, 'OBS_VALUE');
|
|
300
|
+
let rate = readRateCell(value, where);
|
|
301
|
+
if (rate !== null) {
|
|
302
|
+
// The file's own DECIMALS, capped like parseEcbSdmxDecimals; else the caller's, as given.
|
|
303
|
+
const stated = optionalCell(cells, columns, 'DECIMALS');
|
|
304
|
+
let decimals = options?.decimals?.[currency];
|
|
305
|
+
if (stated !== undefined && stated !== '') {
|
|
306
|
+
decimals = statedDecimals(currency, stated);
|
|
307
|
+
}
|
|
308
|
+
if (decimals !== undefined) {
|
|
309
|
+
rate = withDecimals(rate, decimals);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const isoDay = calendarDay(
|
|
314
|
+
Number(day.slice(0, 4)),
|
|
315
|
+
Number(day.slice(5, 7)),
|
|
316
|
+
Number(day.slice(8)),
|
|
317
|
+
);
|
|
318
|
+
addDayRate(days, isoDay, currency, rate);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tables: RateTable[] = [];
|
|
322
|
+
for (const [day, rates] of days) {
|
|
323
|
+
if (Object.values(rates).some((rate) => rate !== null)) {
|
|
324
|
+
tables.push(ecbTable(day, rates));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return newestFirst(tables);
|
|
328
|
+
}
|
package/src/exact.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// ExactMoney: an exact rational amount for chains of arithmetic that should round once, at the end.
|
|
2
|
+
// It has no formatting, JSON or minor units on purpose: it must be rounded back to Money before it
|
|
3
|
+
// leaves the process.
|
|
4
|
+
import { currencyExponent } from './currency.js';
|
|
5
|
+
import { pow10n, ratio, type Factor } from './decimal.js';
|
|
6
|
+
import { CurrencyMismatchError, make, type Money } from './money.js';
|
|
7
|
+
import type { Rate } from './rate.js';
|
|
8
|
+
import { DEFAULT_ROUNDING, divideAndRoundBigint, type RoundingMode } from './rounding.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A rounded value and what rounding left out: `value + residual` is exactly the unrounded amount.
|
|
12
|
+
*/
|
|
13
|
+
export interface Rounded<C extends string> {
|
|
14
|
+
readonly value: Money<C>;
|
|
15
|
+
readonly residual: ExactMoney<C>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* An exact amount of `currency`: num/den major units, reduced, den > 0. Build it with `exact()`.
|
|
20
|
+
*/
|
|
21
|
+
export class ExactMoney<C extends string = string> {
|
|
22
|
+
readonly currency: C;
|
|
23
|
+
readonly num: bigint;
|
|
24
|
+
readonly den: bigint;
|
|
25
|
+
|
|
26
|
+
/** @internal use exact() */
|
|
27
|
+
constructor(currency: C, numerator: bigint, denominator: bigint) {
|
|
28
|
+
const reduced = ratio(numerator, denominator); // reduces and makes the denominator positive
|
|
29
|
+
this.currency = currency;
|
|
30
|
+
this.num = reduced.num;
|
|
31
|
+
this.den = reduced.den;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** this + other, exactly. */
|
|
35
|
+
add(other: ExactMoney<C>): ExactMoney<C> {
|
|
36
|
+
assertSameCurrency(this, other);
|
|
37
|
+
const numerator = this.num * other.den + other.num * this.den;
|
|
38
|
+
return new ExactMoney(this.currency, numerator, this.den * other.den);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** this − other, exactly. */
|
|
42
|
+
subtract(other: ExactMoney<C>): ExactMoney<C> {
|
|
43
|
+
return this.add(other.negate());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** −this. */
|
|
47
|
+
negate(): ExactMoney<C> {
|
|
48
|
+
return new ExactMoney(this.currency, -this.num, this.den);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** this × factor, exactly. */
|
|
52
|
+
multiply(factor: Factor): ExactMoney<C> {
|
|
53
|
+
const factorRatio = ratio(factor);
|
|
54
|
+
return new ExactMoney(this.currency, this.num * factorRatio.num, this.den * factorRatio.den);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** this ÷ divisor, exactly. */
|
|
58
|
+
divide(divisor: Factor): ExactMoney<C> {
|
|
59
|
+
const divisorRatio = ratio(divisor);
|
|
60
|
+
if (divisorRatio.num === 0n) {
|
|
61
|
+
throw new RangeError('Division by zero');
|
|
62
|
+
}
|
|
63
|
+
return new ExactMoney(this.currency, this.num * divisorRatio.den, this.den * divisorRatio.num);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Converts exactly at `rate`; nothing is rounded. */
|
|
67
|
+
convert<To extends string>(rate: Rate<C, To>): ExactMoney<To> {
|
|
68
|
+
if (rate.from !== this.currency) {
|
|
69
|
+
throw new CurrencyMismatchError(rate.from, this.currency);
|
|
70
|
+
}
|
|
71
|
+
return new ExactMoney(rate.to, this.num * rate.ratio.num, this.den * rate.ratio.den);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** −1, 0 or 1. */
|
|
75
|
+
compare(other: ExactMoney<C>): -1 | 0 | 1 {
|
|
76
|
+
assertSameCurrency(this, other);
|
|
77
|
+
// Both denominators are positive, so cross-multiplying keeps the order.
|
|
78
|
+
const left = this.num * other.den;
|
|
79
|
+
const right = other.num * this.den;
|
|
80
|
+
if (left < right) {
|
|
81
|
+
return -1;
|
|
82
|
+
}
|
|
83
|
+
if (left > right) {
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Same currency and the same exact value. */
|
|
90
|
+
equals(other: ExactMoney): boolean {
|
|
91
|
+
return this.currency === other.currency && this.num === other.num && this.den === other.den;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** −1, 0 or 1. */
|
|
95
|
+
sign(): -1 | 0 | 1 {
|
|
96
|
+
if (this.num > 0n) {
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
if (this.num < 0n) {
|
|
100
|
+
return -1;
|
|
101
|
+
}
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Whether the amount is zero. */
|
|
106
|
+
isZero(): boolean {
|
|
107
|
+
return this.num === 0n;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether the amount fits `scale` (default: the currency's exponent) without rounding. */
|
|
111
|
+
isExact(scale: number = currencyExponent(this.currency)): boolean {
|
|
112
|
+
return (this.num * pow10n(scale)) % this.den === 0n;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Rounds once to `scale` (default: the currency's exponent). */
|
|
116
|
+
round(
|
|
117
|
+
rounding: RoundingMode = DEFAULT_ROUNDING,
|
|
118
|
+
scale: number = currencyExponent(this.currency),
|
|
119
|
+
): Money<C> {
|
|
120
|
+
const minor = divideAndRoundBigint(this.num * pow10n(scale), this.den, rounding);
|
|
121
|
+
return make(this.currency, minor, scale);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Rounds once and reports the difference, for booking it (e.g. an "FX rounding" ledger line). */
|
|
125
|
+
roundWithResidual(rounding: RoundingMode = DEFAULT_ROUNDING, scale?: number): Rounded<C> {
|
|
126
|
+
const value = this.round(rounding, scale);
|
|
127
|
+
const residual = this.subtract(exact(value));
|
|
128
|
+
return { value, residual };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** "10/3 EUR", for debugging only. */
|
|
132
|
+
toString(): string {
|
|
133
|
+
if (this.den === 1n) {
|
|
134
|
+
return `${this.num} ${this.currency}`;
|
|
135
|
+
}
|
|
136
|
+
return `${this.num}/${this.den} ${this.currency}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Blocks `a + b` and `a > b`: round() first. */
|
|
140
|
+
[Symbol.toPrimitive](hint: string): string {
|
|
141
|
+
if (hint === 'string') {
|
|
142
|
+
return this.toString();
|
|
143
|
+
}
|
|
144
|
+
throw new TypeError('ExactMoney cannot be converted to a number; round() it first');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertSameCurrency(money: ExactMoney, other: ExactMoney): void {
|
|
149
|
+
if (money.currency !== other.currency) {
|
|
150
|
+
throw new CurrencyMismatchError(money.currency, other.currency);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The exact value of `money`, for chained arithmetic: `exact(price).divide(3).round('halfEven')`.
|
|
156
|
+
*/
|
|
157
|
+
export function exact<C extends string>(money: Money<C>): ExactMoney<C> {
|
|
158
|
+
return new ExactMoney(money.currency, BigInt(money.minor), pow10n(money.scale));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The exact sum (zero of `currency` for an empty list). Round it once for a batch's rounding line.
|
|
163
|
+
*/
|
|
164
|
+
export function sumExact<C extends string>(
|
|
165
|
+
items: readonly ExactMoney<C>[],
|
|
166
|
+
currency?: C,
|
|
167
|
+
): ExactMoney<C> {
|
|
168
|
+
const sumCurrency = currency ?? items[0]?.currency;
|
|
169
|
+
if (sumCurrency === undefined) {
|
|
170
|
+
throw new RangeError('sumExact of an empty list needs a currency');
|
|
171
|
+
}
|
|
172
|
+
let sum = new ExactMoney(sumCurrency, 0n, 1n);
|
|
173
|
+
for (const item of items) {
|
|
174
|
+
sum = sum.add(item);
|
|
175
|
+
}
|
|
176
|
+
return sum;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Whether `value` is an ExactMoney. */
|
|
180
|
+
export function isExactMoney(value: unknown): value is ExactMoney {
|
|
181
|
+
return value instanceof ExactMoney;
|
|
182
|
+
}
|
package/src/fed.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// `@ducatjs/money/convert/fed`: an offline parser for the Federal Reserve Board's H.10 foreign
|
|
2
|
+
// exchange rates. Parser only, no fetcher: see parseFedH10Csv for where the files come from.
|
|
3
|
+
import { quotedTable } from './history.js';
|
|
4
|
+
import type { RateTable } from './provider.js';
|
|
5
|
+
import { addDayRate, csvRows, isCalendarDay, newestFirst, readRateCell } from './rate-text.js';
|
|
6
|
+
|
|
7
|
+
// RXI$US_N.B.EU is USD per euro; RXI_N.B.JA is yen per USD.
|
|
8
|
+
const SERIES = /^(?:H10\/H10\/)?RXI(\$US)?_N\.B\.([A-Z]{2,3})$/;
|
|
9
|
+
|
|
10
|
+
// The country codes in H.10 series IDs, and the currency each one's column must carry.
|
|
11
|
+
const FED_COUNTRIES: Readonly<Record<string, string>> = {
|
|
12
|
+
AL: 'AUD',
|
|
13
|
+
EU: 'EUR',
|
|
14
|
+
NZ: 'NZD',
|
|
15
|
+
UK: 'GBP',
|
|
16
|
+
BZ: 'BRL',
|
|
17
|
+
CA: 'CAD',
|
|
18
|
+
CH: 'CNY',
|
|
19
|
+
DN: 'DKK',
|
|
20
|
+
HK: 'HKD',
|
|
21
|
+
IN: 'INR',
|
|
22
|
+
JA: 'JPY',
|
|
23
|
+
MA: 'MYR',
|
|
24
|
+
MX: 'MXN',
|
|
25
|
+
NO: 'NOK',
|
|
26
|
+
SF: 'ZAR',
|
|
27
|
+
SI: 'SGD',
|
|
28
|
+
KO: 'KRW',
|
|
29
|
+
SL: 'LKR',
|
|
30
|
+
SD: 'SEK',
|
|
31
|
+
SZ: 'CHF',
|
|
32
|
+
TA: 'TWD',
|
|
33
|
+
TH: 'THB',
|
|
34
|
+
VES: 'VEB',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function notH10(why: string): never {
|
|
38
|
+
throw new SyntaxError(`Not a Federal Reserve H.10 CSV file: ${why}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parses the Federal Reserve Board's H.10 daily foreign exchange rates, as CSV from its Data
|
|
43
|
+
* Download Program (DDP), into one USD-based table per day, newest first. These are the noon
|
|
44
|
+
* buying rates in New York, certified by the New York Fed for customs purposes, with `source:
|
|
45
|
+
* 'FRB'` and `type: 'noon buying'`, back to 1971.
|
|
46
|
+
*
|
|
47
|
+
* - **Direction.** H.10 quotes "currency units per U.S. dollar except as noted": AUD, EUR, NZD and
|
|
48
|
+
* GBP (series `RXI$US_N.B.*`) are USD per unit. Those four are stored as exact inverses, so
|
|
49
|
+
* every table is USD → currency, and the published quote stays on the rate:
|
|
50
|
+
* `rate('USD', 'EUR').inverse().toString()` is '1.1750' again.
|
|
51
|
+
* - **Holidays** are rows of `ND` ("no data"); such a day is left out, so a lookback goes on to
|
|
52
|
+
* the day before, and a file of holidays only gives []. A single `ND` cell is a missing value.
|
|
53
|
+
* A file with no observation rows at all throws, as it is more likely cut short than empty.
|
|
54
|
+
* - **Strict:** each column's Unit, Multiplier and Currency must be "Currency", 1 and the code
|
|
55
|
+
* its series ID stands for (EU is EUR), a currency can't have two columns, and two different
|
|
56
|
+
* values for one day throw.
|
|
57
|
+
* - **Digits.** H.10 publishes four decimals and keeps trailing zeros; they are kept.
|
|
58
|
+
* - **Codes.** The Fed's column codes are used as they are, so the bolivar is `VEB`, as the Fed
|
|
59
|
+
* labels it.
|
|
60
|
+
* - **Publication.** The release is weekly (Mondays, 4:15 p.m. ET) and covers the week before,
|
|
61
|
+
* so a Tuesday's rate is known only up to six days later. The CSV doesn't say when each row was
|
|
62
|
+
* released, so no `availableAt` is set; give each table one yourself if a backtest needs it.
|
|
63
|
+
*
|
|
64
|
+
* **Where the data comes from.** The DDP at federalreserve.gov/datadownload (release H10, CSV,
|
|
65
|
+
* "series in columns", with labels) serves these files today. The Board is retiring the DDP: the
|
|
66
|
+
* "Build Your Package" feature goes in November 2026 and the rest later, and it points users to
|
|
67
|
+
* FRED (fred.stlouisfed.org) instead. FRED needs an API key, and its terms forbid storing or
|
|
68
|
+
* caching its content and any commercial use without consent, which is why ducat has no H.10
|
|
69
|
+
* fetcher. Board data itself is public domain ("please cite to the Board as the source").
|
|
70
|
+
*
|
|
71
|
+
* const tables = parseFedH10Csv(await readFile('FRB_H10.csv', 'utf8'));
|
|
72
|
+
*/
|
|
73
|
+
export function parseFedH10Csv(csv: string): RateTable[] {
|
|
74
|
+
const rows = csvRows(csv.charCodeAt(0) === 0xfeff ? csv.slice(1) : csv);
|
|
75
|
+
const series =
|
|
76
|
+
rows.find((cells) => cells[0]?.trim() === 'Time Period') ?? notH10('no "Time Period" row');
|
|
77
|
+
const labelled = (label: string): string[] => {
|
|
78
|
+
const row = rows.find((cells) => cells[0]?.trim() === label);
|
|
79
|
+
if (row === undefined || row.length !== series.length) {
|
|
80
|
+
notH10(`no "${label}" row matching the columns`);
|
|
81
|
+
}
|
|
82
|
+
return row;
|
|
83
|
+
};
|
|
84
|
+
const currencies = labelled('Currency:');
|
|
85
|
+
const multipliers = labelled('Multiplier:');
|
|
86
|
+
const units = labelled('Unit:');
|
|
87
|
+
|
|
88
|
+
// Each column: its currency, and whether it is quoted as USD per unit (to be inverted).
|
|
89
|
+
const columns: { currency: string; inverted: boolean }[] = [];
|
|
90
|
+
for (let i = 1; i < series.length; i++) {
|
|
91
|
+
const id = SERIES.exec(series[i]!.trim());
|
|
92
|
+
const currency = currencies[i]!.trim();
|
|
93
|
+
if (id === null || !/^[A-Z]{3}$/.test(currency) || currency === 'USD') {
|
|
94
|
+
notH10(`column ${i + 1} is ${series[i]} for ${currency}`);
|
|
95
|
+
}
|
|
96
|
+
// The series ID names the country; it must agree with the currency code beside it.
|
|
97
|
+
const expected = FED_COUNTRIES[id[2]!];
|
|
98
|
+
if (expected !== undefined && expected !== currency) {
|
|
99
|
+
notH10(`series ${series[i]} is ${expected}, not ${currency}`);
|
|
100
|
+
}
|
|
101
|
+
if (columns.some((column) => column.currency === currency)) {
|
|
102
|
+
notH10(`${currency} has two columns`);
|
|
103
|
+
}
|
|
104
|
+
// Other units or a multiplier other than 1 would make the published number mean something else.
|
|
105
|
+
if (units[i]!.trim() !== 'Currency' || multipliers[i]!.trim() !== '1') {
|
|
106
|
+
throw new SyntaxError(
|
|
107
|
+
`Unsupported H.10 unit ${units[i]} × ${multipliers[i]} for ${currency}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
columns.push({ currency, inverted: id[1] !== undefined });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const observations = rows.slice(rows.indexOf(series) + 1);
|
|
114
|
+
if (observations.length === 0) {
|
|
115
|
+
notH10('no observations');
|
|
116
|
+
}
|
|
117
|
+
const days = new Map<string, Record<string, string | null>>();
|
|
118
|
+
for (const cells of observations) {
|
|
119
|
+
const day = cells[0]!.trim();
|
|
120
|
+
if (!isCalendarDay(day) || cells.length !== series.length) {
|
|
121
|
+
throw new SyntaxError(`Malformed H.10 row: ${cells.join(',')}`);
|
|
122
|
+
}
|
|
123
|
+
columns.forEach(({ currency }, i) => {
|
|
124
|
+
addDayRate(days, day, currency, readRateCell(cells[i + 1]!, `${currency} on ${day}`));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const tables: RateTable[] = [];
|
|
129
|
+
for (const [day, published] of days) {
|
|
130
|
+
const rates: Record<string, { rate: string; inverted: boolean } | null> = {};
|
|
131
|
+
let quoted = 0;
|
|
132
|
+
for (const { currency, inverted } of columns) {
|
|
133
|
+
const rate = published[currency] ?? null;
|
|
134
|
+
rates[currency] = rate === null ? null : { rate, inverted };
|
|
135
|
+
if (rate !== null) {
|
|
136
|
+
quoted++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (quoted > 0) {
|
|
140
|
+
tables.push(
|
|
141
|
+
quotedTable({ base: 'USD', source: 'FRB', type: 'noon buying', asOf: day, rates }),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return newestFirst(tables);
|
|
146
|
+
}
|