@ducatjs/money 0.1.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.
Files changed (201) hide show
  1. package/README.md +479 -62
  2. package/data/cash-rounding.json +123 -2
  3. package/data/cldr-cash.json +101 -0
  4. package/data/historic.json +78 -0
  5. package/data/iso4217-list-three.xml +1185 -0
  6. package/dist/adyen-fx.d.ts.map +1 -1
  7. package/dist/adyen-fx.js +22 -4
  8. package/dist/adyen-fx.js.map +1 -1
  9. package/dist/boc.d.ts +31 -0
  10. package/dist/boc.d.ts.map +1 -0
  11. package/dist/boc.js +114 -0
  12. package/dist/boc.js.map +1 -0
  13. package/dist/boe.d.ts +33 -0
  14. package/dist/boe.d.ts.map +1 -0
  15. package/dist/boe.js +114 -0
  16. package/dist/boe.js.map +1 -0
  17. package/dist/cash.d.ts +30 -1
  18. package/dist/cash.d.ts.map +1 -1
  19. package/dist/cash.js +50 -2
  20. package/dist/cash.js.map +1 -1
  21. package/dist/convert.d.ts +10 -9
  22. package/dist/convert.d.ts.map +1 -1
  23. package/dist/convert.js +13 -3
  24. package/dist/convert.js.map +1 -1
  25. package/dist/drizzle/mysql.d.ts +105 -0
  26. package/dist/drizzle/mysql.d.ts.map +1 -0
  27. package/dist/drizzle/mysql.js +60 -0
  28. package/dist/drizzle/mysql.js.map +1 -0
  29. package/dist/drizzle/pg.d.ts +103 -0
  30. package/dist/drizzle/pg.d.ts.map +1 -0
  31. package/dist/drizzle/pg.js +60 -0
  32. package/dist/drizzle/pg.js.map +1 -0
  33. package/dist/drizzle/sqlite.d.ts +102 -0
  34. package/dist/drizzle/sqlite.d.ts.map +1 -0
  35. package/dist/drizzle/sqlite.js +59 -0
  36. package/dist/drizzle/sqlite.js.map +1 -0
  37. package/dist/ecb.d.ts +37 -2
  38. package/dist/ecb.d.ts.map +1 -1
  39. package/dist/ecb.js +148 -10
  40. package/dist/ecb.js.map +1 -1
  41. package/dist/fed.d.ts +35 -0
  42. package/dist/fed.d.ts.map +1 -0
  43. package/dist/fed.js +134 -0
  44. package/dist/fed.js.map +1 -0
  45. package/dist/fetch.d.ts +45 -0
  46. package/dist/fetch.d.ts.map +1 -1
  47. package/dist/fetch.js +167 -24
  48. package/dist/fetch.js.map +1 -1
  49. package/dist/fn.d.ts +8 -1
  50. package/dist/fn.d.ts.map +1 -1
  51. package/dist/fn.js +8 -1
  52. package/dist/fn.js.map +1 -1
  53. package/dist/generated/cash.d.ts +2 -0
  54. package/dist/generated/cash.d.ts.map +1 -1
  55. package/dist/generated/cash.js +101 -0
  56. package/dist/generated/cash.js.map +1 -1
  57. package/dist/generated/historic.d.ts +337 -0
  58. package/dist/generated/historic.d.ts.map +1 -0
  59. package/dist/generated/historic.js +66 -0
  60. package/dist/generated/historic.js.map +1 -0
  61. package/dist/historic.d.ts +40 -0
  62. package/dist/historic.d.ts.map +1 -0
  63. package/dist/historic.js +43 -0
  64. package/dist/historic.js.map +1 -0
  65. package/dist/history.d.ts +96 -0
  66. package/dist/history.d.ts.map +1 -0
  67. package/dist/history.js +213 -0
  68. package/dist/history.js.map +1 -0
  69. package/dist/hmrc.d.ts +28 -0
  70. package/dist/hmrc.d.ts.map +1 -0
  71. package/dist/hmrc.js +108 -0
  72. package/dist/hmrc.js.map +1 -0
  73. package/dist/index.d.ts +1 -1
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/kysely.d.ts +120 -0
  78. package/dist/kysely.d.ts.map +1 -0
  79. package/dist/kysely.js +309 -0
  80. package/dist/kysely.js.map +1 -0
  81. package/dist/money.d.ts +93 -9
  82. package/dist/money.d.ts.map +1 -1
  83. package/dist/money.js +135 -9
  84. package/dist/money.js.map +1 -1
  85. package/dist/parse.d.ts +45 -1
  86. package/dist/parse.d.ts.map +1 -1
  87. package/dist/parse.js +290 -3
  88. package/dist/parse.js.map +1 -1
  89. package/dist/pg.d.ts +100 -0
  90. package/dist/pg.d.ts.map +1 -0
  91. package/dist/pg.js +343 -0
  92. package/dist/pg.js.map +1 -0
  93. package/dist/plugins/all.d.ts +8 -1
  94. package/dist/plugins/all.d.ts.map +1 -1
  95. package/dist/plugins/all.js +8 -1
  96. package/dist/plugins/all.js.map +1 -1
  97. package/dist/plugins/convert.d.ts +3 -4
  98. package/dist/plugins/convert.d.ts.map +1 -1
  99. package/dist/plugins/convert.js.map +1 -1
  100. package/dist/plugins/tax.d.ts +4 -5
  101. package/dist/plugins/tax.d.ts.map +1 -1
  102. package/dist/plugins/tax.js.map +1 -1
  103. package/dist/policy.d.ts +34 -0
  104. package/dist/policy.d.ts.map +1 -0
  105. package/dist/policy.js +90 -0
  106. package/dist/policy.js.map +1 -0
  107. package/dist/prisma.d.ts +25 -14
  108. package/dist/prisma.d.ts.map +1 -1
  109. package/dist/prisma.js +29 -19
  110. package/dist/prisma.js.map +1 -1
  111. package/dist/provider.d.ts +21 -7
  112. package/dist/provider.d.ts.map +1 -1
  113. package/dist/provider.js +51 -11
  114. package/dist/provider.js.map +1 -1
  115. package/dist/rate-text.d.ts +38 -0
  116. package/dist/rate-text.d.ts.map +1 -0
  117. package/dist/rate-text.js +143 -0
  118. package/dist/rate-text.js.map +1 -0
  119. package/dist/rate.d.ts +61 -5
  120. package/dist/rate.d.ts.map +1 -1
  121. package/dist/rate.js +133 -33
  122. package/dist/rate.js.map +1 -1
  123. package/dist/rounding.js +1 -1
  124. package/dist/rounding.js.map +1 -1
  125. package/dist/schema.d.ts +43 -0
  126. package/dist/schema.d.ts.map +1 -0
  127. package/dist/schema.js +77 -0
  128. package/dist/schema.js.map +1 -0
  129. package/dist/sql.d.ts +54 -0
  130. package/dist/sql.d.ts.map +1 -0
  131. package/dist/sql.js +131 -0
  132. package/dist/sql.js.map +1 -0
  133. package/dist/tax.d.ts +13 -9
  134. package/dist/tax.d.ts.map +1 -1
  135. package/dist/tax.js +20 -4
  136. package/dist/tax.js.map +1 -1
  137. package/dist/testing/arbitraries.d.ts +43 -0
  138. package/dist/testing/arbitraries.d.ts.map +1 -0
  139. package/dist/testing/arbitraries.js +98 -0
  140. package/dist/testing/arbitraries.js.map +1 -0
  141. package/dist/testing/index.d.ts +67 -0
  142. package/dist/testing/index.d.ts.map +1 -0
  143. package/dist/testing/index.js +159 -0
  144. package/dist/testing/index.js.map +1 -0
  145. package/dist/testing/jest.d.ts +2 -0
  146. package/dist/testing/jest.d.ts.map +1 -0
  147. package/dist/testing/jest.js +15 -0
  148. package/dist/testing/jest.js.map +1 -0
  149. package/dist/testing/vitest.d.ts +7 -0
  150. package/dist/testing/vitest.d.ts.map +1 -0
  151. package/dist/testing/vitest.js +12 -0
  152. package/dist/testing/vitest.js.map +1 -0
  153. package/dist/valibot.d.ts +33 -0
  154. package/dist/valibot.d.ts.map +1 -0
  155. package/dist/valibot.js +61 -0
  156. package/dist/valibot.js.map +1 -0
  157. package/dist/zod.d.ts +36 -0
  158. package/dist/zod.d.ts.map +1 -0
  159. package/dist/zod.js +58 -0
  160. package/dist/zod.js.map +1 -0
  161. package/package.json +116 -5
  162. package/src/adyen-fx.ts +25 -4
  163. package/src/boc.ts +136 -0
  164. package/src/boe.ts +131 -0
  165. package/src/cash.ts +70 -2
  166. package/src/convert.ts +33 -15
  167. package/src/drizzle/mysql.ts +78 -0
  168. package/src/drizzle/pg.ts +78 -0
  169. package/src/drizzle/sqlite.ts +70 -0
  170. package/src/ecb.ts +179 -13
  171. package/src/fed.ts +146 -0
  172. package/src/fetch.ts +207 -28
  173. package/src/fn.ts +14 -0
  174. package/src/generated/cash.ts +103 -0
  175. package/src/generated/historic.ts +67 -0
  176. package/src/historic.ts +77 -0
  177. package/src/history.ts +333 -0
  178. package/src/hmrc.ts +125 -0
  179. package/src/index.ts +4 -0
  180. package/src/kysely.ts +588 -0
  181. package/src/money.ts +210 -12
  182. package/src/parse.ts +371 -4
  183. package/src/pg.ts +410 -0
  184. package/src/plugins/all.ts +10 -0
  185. package/src/plugins/convert.ts +4 -5
  186. package/src/plugins/tax.ts +9 -10
  187. package/src/policy.ts +124 -0
  188. package/src/prisma.ts +91 -10
  189. package/src/provider.ts +81 -16
  190. package/src/rate-text.ts +151 -0
  191. package/src/rate.ts +205 -36
  192. package/src/rounding.ts +1 -1
  193. package/src/schema.ts +119 -0
  194. package/src/sql.ts +178 -0
  195. package/src/tax.ts +40 -9
  196. package/src/testing/arbitraries.ts +134 -0
  197. package/src/testing/index.ts +247 -0
  198. package/src/testing/jest.ts +18 -0
  199. package/src/testing/vitest.ts +18 -0
  200. package/src/valibot.ts +87 -0
  201. package/src/zod.ts +78 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `@ducatjs/money/drizzle/sqlite`: Drizzle ORM column types for SQLite (better-sqlite3, libSQL,
3
+ * Bun). Selects return `Money`, inserts and updates accept it, and the currency is part of the
4
+ * column's type.
5
+ *
6
+ * import { sqliteTable } from 'drizzle-orm/sqlite-core';
7
+ * import { moneyMinor } from '@ducatjs/money/drizzle/sqlite';
8
+ *
9
+ * const orders = sqliteTable('orders', { total: moneyMinor('EUR')('total_minor').notNull() });
10
+ */
11
+ import { customType } from 'drizzle-orm/sqlite-core';
12
+ import { currencyExponent } from '../currency.js';
13
+ import type { CurrencyInput, Money } from '../money.js';
14
+ import {
15
+ decimalFromDriver,
16
+ decimalToDriver,
17
+ jsonFromDriver,
18
+ jsonToDriver,
19
+ minorFromDriver,
20
+ minorToDriver,
21
+ } from '../sql.js';
22
+
23
+ /**
24
+ * An INTEGER column of minor units in a fixed currency (`1234` is 12.34 EUR), read back as
25
+ * `Money<C>`. Exact up to SQLite's limit of ±9.2·10^18 minor units. better-sqlite3 returns integers
26
+ * as numbers by default, which are exact only below 2^53; beyond that reading throws, so call
27
+ * `defaultSafeIntegers(true)` on the database for larger amounts. Writing another currency, or a
28
+ * value at a scale other than the currency's, throws.
29
+ *
30
+ * total: moneyMinor('EUR')('total_minor').notNull()
31
+ */
32
+ export function moneyMinor<const C extends CurrencyInput>(currency: C) {
33
+ return customType<{ data: Money<C>; driverData: number | bigint }>({
34
+ dataType: () => 'integer',
35
+ toDriver: (money) => minorToDriver(money, currency),
36
+ fromDriver: (value) => minorFromDriver(value, currency),
37
+ });
38
+ }
39
+
40
+ /**
41
+ * A TEXT column holding the exact decimal string (`12.34`), read back as `Money<C>`. SQLite's
42
+ * NUMERIC affinity would store some decimals as REAL and lose digits, so they are kept as text.
43
+ * Writing another currency, or a value at a scale other than the currency's, throws.
44
+ *
45
+ * price: moneyDecimal('EUR')('price').notNull()
46
+ */
47
+ export function moneyDecimal<const C extends CurrencyInput>(currency: C) {
48
+ // fail at the schema definition, not on the first query, for an unknown currency
49
+ currencyExponent(currency);
50
+
51
+ return customType<{ data: Money<C>; driverData: string }>({
52
+ dataType: () => 'text',
53
+ toDriver: (money) => decimalToDriver(money, currency, undefined),
54
+ fromDriver: (value) => decimalFromDriver(value, currency),
55
+ });
56
+ }
57
+
58
+ /**
59
+ * A TEXT column holding `{"amount":"12.34","currency":"EUR"}`, with the currency in each value.
60
+ * Pass a currency to fix it: then a value in another currency throws, on write and on read.
61
+ *
62
+ * refund: moneyJson()('refund')
63
+ */
64
+ export function moneyJson<const C extends CurrencyInput = CurrencyInput>(currency?: C) {
65
+ return customType<{ data: Money<C>; driverData: string }>({
66
+ dataType: () => 'text',
67
+ toDriver: (money) => jsonToDriver(money, currency),
68
+ fromDriver: (value) => jsonFromDriver(value, currency),
69
+ });
70
+ }
package/src/ecb.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Offline parsers for the ECB euro foreign exchange reference rates (eurofxref). No I/O: fetch or
2
2
  // read the files yourself, or use ducat/convert/fetch. Rates are kept exactly as published.
3
3
  import { rateTable, type RateTable } from './provider.js';
4
+ import { addDayRate, csvRows, newestFirst, readRateCell, withDecimals } from './rate-text.js';
4
5
 
5
6
  const MONTHS = [
6
7
  'january',
@@ -28,7 +29,7 @@ const ISO_DAY = /^(\d{4})-(\d\d)-(\d\d)$/;
28
29
  * a regular concertation between central banks, and published for information only. They are not a
29
30
  * market mid-rate that anyone deals at.
30
31
  */
31
- function ecbTable(asOf: string, rates: Record<string, string>): RateTable {
32
+ function ecbTable(asOf: string, rates: Record<string, string | null>): RateTable {
32
33
  return rateTable({ base: 'EUR', asOf, source: 'ECB', type: 'reference', rates });
33
34
  }
34
35
 
@@ -67,10 +68,6 @@ function ratesOfDay(body: string): Record<string, string> {
67
68
  return rates;
68
69
  }
69
70
 
70
- function newestFirst(tables: RateTable[]): RateTable[] {
71
- return tables.sort((table, other) => other.asOf!.localeCompare(table.asOf!));
72
- }
73
-
74
71
  /**
75
72
  * Parses eurofxref-daily.xml, eurofxref-hist-90d.xml or eurofxref-hist.xml into one EUR-based table
76
73
  * per day, newest first. Each rate is EUR → currency, with `source: 'ECB'`, `type: 'reference'` and
@@ -130,8 +127,8 @@ function isoDay(text: string): string {
130
127
 
131
128
  /**
132
129
  * Parses eurofxref.csv or eurofxref-hist.csv (from the ECB's zips) the way parseEcbXml does.
133
- * 'N/A' and blank cells (currencies not quoted that day) are skipped, and a day with no rates at
134
- * all is left out, so a lookback goes on to the day before.
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.
135
132
  */
136
133
  export function parseEcbCsv(csv: string): RateTable[] {
137
134
  const lines = withoutByteOrderMark(csv)
@@ -145,16 +142,185 @@ export function parseEcbCsv(csv: string): RateTable[] {
145
142
  const tables: RateTable[] = [];
146
143
  for (const line of lines.slice(1)) {
147
144
  const cells = line.split(',').map((cell) => cell.trim());
148
- const rates: Record<string, string> = {};
145
+ const day = isoDay(cells[0]!);
146
+ const rates: Record<string, string | null> = {};
147
+ let quoted = 0;
149
148
  for (let i = 1; i < columns.length; i++) {
150
149
  const currency = columns[i];
151
- const rate = cells[i];
152
- if (currency && rate && rate !== 'N/A') {
153
- rates[currency] = rate;
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++;
154
157
  }
155
158
  }
156
- const day = isoDay(cells[0]!);
157
- if (Object.keys(rates).length > 0) {
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)) {
158
324
  tables.push(ecbTable(day, rates));
159
325
  }
160
326
  }
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
+ }