@ducatjs/money 0.1.0 → 0.2.1

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 (253) hide show
  1. package/NOTICE +73 -0
  2. package/README.md +479 -62
  3. package/data/cash-rounding.json +123 -2
  4. package/data/cldr-cash.json +101 -0
  5. package/data/historic.json +78 -0
  6. package/data/iso4217-list-three.xml +1185 -0
  7. package/dist/adyen-fx.d.ts.map +1 -1
  8. package/dist/adyen-fx.js +22 -4
  9. package/dist/adyen-fx.js.map +1 -1
  10. package/dist/boc.d.ts +31 -0
  11. package/dist/boc.d.ts.map +1 -0
  12. package/dist/boc.js +114 -0
  13. package/dist/boc.js.map +1 -0
  14. package/dist/boe.d.ts +33 -0
  15. package/dist/boe.d.ts.map +1 -0
  16. package/dist/boe.js +114 -0
  17. package/dist/boe.js.map +1 -0
  18. package/dist/cash.d.ts +30 -1
  19. package/dist/cash.d.ts.map +1 -1
  20. package/dist/cash.js +50 -2
  21. package/dist/cash.js.map +1 -1
  22. package/dist/convert.d.ts +10 -9
  23. package/dist/convert.d.ts.map +1 -1
  24. package/dist/convert.js +13 -3
  25. package/dist/convert.js.map +1 -1
  26. package/dist/drizzle/mysql.d.ts +105 -0
  27. package/dist/drizzle/mysql.d.ts.map +1 -0
  28. package/dist/drizzle/mysql.js +60 -0
  29. package/dist/drizzle/mysql.js.map +1 -0
  30. package/dist/drizzle/pg.d.ts +103 -0
  31. package/dist/drizzle/pg.d.ts.map +1 -0
  32. package/dist/drizzle/pg.js +60 -0
  33. package/dist/drizzle/pg.js.map +1 -0
  34. package/dist/drizzle/sqlite.d.ts +102 -0
  35. package/dist/drizzle/sqlite.d.ts.map +1 -0
  36. package/dist/drizzle/sqlite.js +59 -0
  37. package/dist/drizzle/sqlite.js.map +1 -0
  38. package/dist/ecb.d.ts +37 -2
  39. package/dist/ecb.d.ts.map +1 -1
  40. package/dist/ecb.js +148 -10
  41. package/dist/ecb.js.map +1 -1
  42. package/dist/fed.d.ts +35 -0
  43. package/dist/fed.d.ts.map +1 -0
  44. package/dist/fed.js +134 -0
  45. package/dist/fed.js.map +1 -0
  46. package/dist/fetch.d.ts +45 -0
  47. package/dist/fetch.d.ts.map +1 -1
  48. package/dist/fetch.js +167 -24
  49. package/dist/fetch.js.map +1 -1
  50. package/dist/fn.d.ts +8 -1
  51. package/dist/fn.d.ts.map +1 -1
  52. package/dist/fn.js +8 -1
  53. package/dist/fn.js.map +1 -1
  54. package/dist/generated/cash.d.ts +2 -0
  55. package/dist/generated/cash.d.ts.map +1 -1
  56. package/dist/generated/cash.js +101 -0
  57. package/dist/generated/cash.js.map +1 -1
  58. package/dist/generated/historic.d.ts +337 -0
  59. package/dist/generated/historic.d.ts.map +1 -0
  60. package/dist/generated/historic.js +66 -0
  61. package/dist/generated/historic.js.map +1 -0
  62. package/dist/historic.d.ts +40 -0
  63. package/dist/historic.d.ts.map +1 -0
  64. package/dist/historic.js +43 -0
  65. package/dist/historic.js.map +1 -0
  66. package/dist/history.d.ts +96 -0
  67. package/dist/history.d.ts.map +1 -0
  68. package/dist/history.js +213 -0
  69. package/dist/history.js.map +1 -0
  70. package/dist/hmrc.d.ts +28 -0
  71. package/dist/hmrc.d.ts.map +1 -0
  72. package/dist/hmrc.js +108 -0
  73. package/dist/hmrc.js.map +1 -0
  74. package/dist/index.d.ts +1 -1
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/kysely.d.ts +120 -0
  79. package/dist/kysely.d.ts.map +1 -0
  80. package/dist/kysely.js +309 -0
  81. package/dist/kysely.js.map +1 -0
  82. package/dist/money.d.ts +93 -9
  83. package/dist/money.d.ts.map +1 -1
  84. package/dist/money.js +135 -9
  85. package/dist/money.js.map +1 -1
  86. package/dist/parse.d.ts +45 -1
  87. package/dist/parse.d.ts.map +1 -1
  88. package/dist/parse.js +290 -3
  89. package/dist/parse.js.map +1 -1
  90. package/dist/pg.d.ts +100 -0
  91. package/dist/pg.d.ts.map +1 -0
  92. package/dist/pg.js +343 -0
  93. package/dist/pg.js.map +1 -0
  94. package/dist/plugins/adyen.d.ts +1 -1
  95. package/dist/plugins/adyen.d.ts.map +1 -1
  96. package/dist/plugins/adyen.js.map +1 -1
  97. package/dist/plugins/all.d.ts +8 -1
  98. package/dist/plugins/all.d.ts.map +1 -1
  99. package/dist/plugins/all.js +8 -1
  100. package/dist/plugins/all.js.map +1 -1
  101. package/dist/plugins/allocate.d.ts +1 -1
  102. package/dist/plugins/allocate.d.ts.map +1 -1
  103. package/dist/plugins/allocate.js.map +1 -1
  104. package/dist/plugins/braintree.d.ts +1 -1
  105. package/dist/plugins/braintree.d.ts.map +1 -1
  106. package/dist/plugins/braintree.js.map +1 -1
  107. package/dist/plugins/cash.d.ts +1 -1
  108. package/dist/plugins/cash.d.ts.map +1 -1
  109. package/dist/plugins/cash.js.map +1 -1
  110. package/dist/plugins/checkout.d.ts +1 -1
  111. package/dist/plugins/checkout.d.ts.map +1 -1
  112. package/dist/plugins/checkout.js.map +1 -1
  113. package/dist/plugins/convert.d.ts +5 -5
  114. package/dist/plugins/convert.d.ts.map +1 -1
  115. package/dist/plugins/convert.js.map +1 -1
  116. package/dist/plugins/exact.d.ts +1 -1
  117. package/dist/plugins/exact.d.ts.map +1 -1
  118. package/dist/plugins/exact.js.map +1 -1
  119. package/dist/plugins/fixed.d.ts +1 -1
  120. package/dist/plugins/fixed.d.ts.map +1 -1
  121. package/dist/plugins/fixed.js.map +1 -1
  122. package/dist/plugins/format.d.ts +1 -1
  123. package/dist/plugins/format.d.ts.map +1 -1
  124. package/dist/plugins/format.js.map +1 -1
  125. package/dist/plugins/klarna.d.ts +1 -1
  126. package/dist/plugins/klarna.d.ts.map +1 -1
  127. package/dist/plugins/klarna.js.map +1 -1
  128. package/dist/plugins/paypal.d.ts +1 -1
  129. package/dist/plugins/paypal.d.ts.map +1 -1
  130. package/dist/plugins/paypal.js +0 -4
  131. package/dist/plugins/paypal.js.map +1 -1
  132. package/dist/plugins/prisma.d.ts +1 -1
  133. package/dist/plugins/prisma.d.ts.map +1 -1
  134. package/dist/plugins/prisma.js +0 -4
  135. package/dist/plugins/prisma.js.map +1 -1
  136. package/dist/plugins/stripe.d.ts +1 -1
  137. package/dist/plugins/stripe.d.ts.map +1 -1
  138. package/dist/plugins/stripe.js +0 -4
  139. package/dist/plugins/stripe.js.map +1 -1
  140. package/dist/plugins/tax.d.ts +6 -6
  141. package/dist/plugins/tax.d.ts.map +1 -1
  142. package/dist/plugins/tax.js.map +1 -1
  143. package/dist/policy.d.ts +34 -0
  144. package/dist/policy.d.ts.map +1 -0
  145. package/dist/policy.js +90 -0
  146. package/dist/policy.js.map +1 -0
  147. package/dist/prisma.d.ts +25 -14
  148. package/dist/prisma.d.ts.map +1 -1
  149. package/dist/prisma.js +29 -19
  150. package/dist/prisma.js.map +1 -1
  151. package/dist/provider.d.ts +21 -7
  152. package/dist/provider.d.ts.map +1 -1
  153. package/dist/provider.js +51 -11
  154. package/dist/provider.js.map +1 -1
  155. package/dist/rate-text.d.ts +38 -0
  156. package/dist/rate-text.d.ts.map +1 -0
  157. package/dist/rate-text.js +143 -0
  158. package/dist/rate-text.js.map +1 -0
  159. package/dist/rate.d.ts +61 -5
  160. package/dist/rate.d.ts.map +1 -1
  161. package/dist/rate.js +133 -33
  162. package/dist/rate.js.map +1 -1
  163. package/dist/rounding.js +1 -1
  164. package/dist/rounding.js.map +1 -1
  165. package/dist/schema.d.ts +43 -0
  166. package/dist/schema.d.ts.map +1 -0
  167. package/dist/schema.js +77 -0
  168. package/dist/schema.js.map +1 -0
  169. package/dist/sql.d.ts +54 -0
  170. package/dist/sql.d.ts.map +1 -0
  171. package/dist/sql.js +131 -0
  172. package/dist/sql.js.map +1 -0
  173. package/dist/tax.d.ts +13 -9
  174. package/dist/tax.d.ts.map +1 -1
  175. package/dist/tax.js +20 -4
  176. package/dist/tax.js.map +1 -1
  177. package/dist/testing/arbitraries.d.ts +43 -0
  178. package/dist/testing/arbitraries.d.ts.map +1 -0
  179. package/dist/testing/arbitraries.js +98 -0
  180. package/dist/testing/arbitraries.js.map +1 -0
  181. package/dist/testing/index.d.ts +67 -0
  182. package/dist/testing/index.d.ts.map +1 -0
  183. package/dist/testing/index.js +159 -0
  184. package/dist/testing/index.js.map +1 -0
  185. package/dist/testing/jest.d.ts +2 -0
  186. package/dist/testing/jest.d.ts.map +1 -0
  187. package/dist/testing/jest.js +15 -0
  188. package/dist/testing/jest.js.map +1 -0
  189. package/dist/testing/vitest.d.ts +7 -0
  190. package/dist/testing/vitest.d.ts.map +1 -0
  191. package/dist/testing/vitest.js +12 -0
  192. package/dist/testing/vitest.js.map +1 -0
  193. package/dist/valibot.d.ts +33 -0
  194. package/dist/valibot.d.ts.map +1 -0
  195. package/dist/valibot.js +61 -0
  196. package/dist/valibot.js.map +1 -0
  197. package/dist/zod.d.ts +36 -0
  198. package/dist/zod.d.ts.map +1 -0
  199. package/dist/zod.js +58 -0
  200. package/dist/zod.js.map +1 -0
  201. package/package.json +116 -5
  202. package/src/adyen-fx.ts +25 -4
  203. package/src/boc.ts +136 -0
  204. package/src/boe.ts +131 -0
  205. package/src/cash.ts +70 -2
  206. package/src/convert.ts +33 -15
  207. package/src/drizzle/mysql.ts +78 -0
  208. package/src/drizzle/pg.ts +78 -0
  209. package/src/drizzle/sqlite.ts +70 -0
  210. package/src/ecb.ts +179 -13
  211. package/src/fed.ts +146 -0
  212. package/src/fetch.ts +207 -28
  213. package/src/fn.ts +14 -0
  214. package/src/generated/cash.ts +103 -0
  215. package/src/generated/historic.ts +67 -0
  216. package/src/historic.ts +77 -0
  217. package/src/history.ts +333 -0
  218. package/src/hmrc.ts +125 -0
  219. package/src/index.ts +4 -0
  220. package/src/kysely.ts +588 -0
  221. package/src/money.ts +210 -12
  222. package/src/parse.ts +371 -4
  223. package/src/pg.ts +410 -0
  224. package/src/plugins/adyen.ts +3 -2
  225. package/src/plugins/all.ts +10 -0
  226. package/src/plugins/allocate.ts +3 -2
  227. package/src/plugins/braintree.ts +3 -2
  228. package/src/plugins/cash.ts +3 -2
  229. package/src/plugins/checkout.ts +3 -2
  230. package/src/plugins/convert.ts +6 -6
  231. package/src/plugins/exact.ts +3 -2
  232. package/src/plugins/fixed.ts +3 -2
  233. package/src/plugins/format.ts +3 -2
  234. package/src/plugins/klarna.ts +3 -2
  235. package/src/plugins/paypal.ts +3 -2
  236. package/src/plugins/prisma.ts +3 -2
  237. package/src/plugins/stripe.ts +3 -2
  238. package/src/plugins/tax.ts +11 -11
  239. package/src/policy.ts +124 -0
  240. package/src/prisma.ts +91 -10
  241. package/src/provider.ts +81 -16
  242. package/src/rate-text.ts +151 -0
  243. package/src/rate.ts +205 -36
  244. package/src/rounding.ts +1 -1
  245. package/src/schema.ts +119 -0
  246. package/src/sql.ts +178 -0
  247. package/src/tax.ts +40 -9
  248. package/src/testing/arbitraries.ts +134 -0
  249. package/src/testing/index.ts +247 -0
  250. package/src/testing/jest.ts +18 -0
  251. package/src/testing/vitest.ts +18 -0
  252. package/src/valibot.ts +87 -0
  253. package/src/zod.ts +78 -0
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
+ }
package/src/fetch.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // Network fetchers for published reference rates, using the platform fetch. Opt-in: nothing else in
2
2
  // the library imports this module, so core and @ducatjs/money/convert never touch the
3
3
  // network. You decide when to call it and how to cache.
4
- import { parseEcbXml } from './ecb.js';
4
+ import { parseBankOfCanadaJson } from './boc.js';
5
+ import { parseEcbSdmxCsv, parseEcbSdmxDecimals, parseEcbXml } from './ecb.js';
5
6
  import type { RateTable } from './provider.js';
6
7
 
7
8
  /**
@@ -73,33 +74,8 @@ export class FetchRatesError extends Error {
73
74
  */
74
75
  export async function fetchEcb(options?: FetchEcbOptions): Promise<RateTable[]> {
75
76
  const url = ECB_URLS[options?.history ?? 'daily'];
76
- const fetchFunction = options?.fetch ?? (globalThis as { fetch?: Fetch }).fetch;
77
- if (fetchFunction === undefined) {
78
- throw new TypeError('No fetch available: pass options.fetch');
79
- }
80
-
81
- const init: { signal?: AbortSignalLike; headers: Record<string, string> } = {
82
- headers: { accept: 'application/xml' },
83
- };
84
- const signal = requestSignal(options?.signal, options?.timeout ?? DEFAULT_TIMEOUT_MS);
85
- if (signal !== undefined) {
86
- init.signal = signal;
87
- }
88
- const response = await fetchFunction(url, init);
89
- if (!response.ok) {
90
- throw new FetchRatesError(url, response.status);
91
- }
92
-
93
- const body = await response.text();
94
- try {
95
- return parseEcbXml(body);
96
- } catch (error) {
97
- // An error page or a captive portal is the usual cause, so say where the bad file came from.
98
- if (error instanceof SyntaxError) {
99
- throw new SyntaxError(`${error.message} (from ${url})`, { cause: error });
100
- }
101
- throw error;
102
- }
77
+ const body = await getText(url, 'application/xml', options);
78
+ return parsedFrom(url, () => parseEcbXml(body));
103
79
  }
104
80
 
105
81
  /** The caller's signal, a timeout signal, or both combined. */
@@ -125,3 +101,206 @@ function requestSignal(
125
101
  }
126
102
  return statics.any([signal, timeoutSignal]);
127
103
  }
104
+
105
+ /** The ECB Data Portal's SDMX REST API (no key, and CORS-enabled, so it works in browsers). */
106
+ export const ECB_DATA_API = 'https://data-api.ecb.europa.eu/service/data/EXR';
107
+
108
+ /** Options for fetching a date range of ECB reference rates. */
109
+ export interface FetchEcbRangeOptions extends Omit<FetchEcbOptions, 'history'> {
110
+ /** The first day ('1999-01-04' at the earliest). */
111
+ readonly start: string | Date;
112
+ /** The last day (default: the latest published). */
113
+ readonly end?: string | Date;
114
+ /** ISO codes to fetch (default: every currency, including discontinued series). */
115
+ readonly currencies?: readonly string[];
116
+ }
117
+
118
+ /**
119
+ * A request's UTC day: a 'YYYY-MM-DD' string that is a real calendar day, or a Date (any
120
+ * offset) as its UTC day. Anything else is a RangeError, before any request is made.
121
+ */
122
+ function requestDay(time: string | Date, name: string): string {
123
+ let day: string;
124
+ if (typeof time === 'string') {
125
+ day = time;
126
+ } else {
127
+ day = Number.isNaN(time.getTime()) ? '' : time.toISOString().slice(0, 10);
128
+ }
129
+ const match = /^(\d{4})-(\d\d)-(\d\d)$/.exec(day);
130
+ const date =
131
+ match === null ? undefined : new Date(Date.UTC(+match[1]!, +match[2]! - 1, +match[3]!));
132
+ // Date.UTC rolls 30 February over into March, so a real day must come back unchanged.
133
+ if (date === undefined || date.toISOString().slice(0, 10) !== day) {
134
+ throw new RangeError(`${name} must be a date, got ${String(time)}`);
135
+ }
136
+ return day;
137
+ }
138
+
139
+ /** Wraps a parser's SyntaxError with the URL the bad file came from (an error page, usually). */
140
+ function parsedFrom<T>(url: string, parse: () => T): T {
141
+ try {
142
+ return parse();
143
+ } catch (error) {
144
+ if (error instanceof SyntaxError) {
145
+ throw new SyntaxError(`${error.message} (from ${url})`, { cause: error });
146
+ }
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ /** The platform's AbortController, where it exists. */
152
+ type AbortControllerLike = { readonly signal: AbortSignalLike; abort(reason?: unknown): void };
153
+
154
+ /** GETs `url` as text, with the timeout and signal handling every fetcher here shares. */
155
+ async function getText(
156
+ url: string,
157
+ accept: string,
158
+ options: Omit<FetchEcbOptions, 'history'> | undefined,
159
+ ): Promise<string> {
160
+ return (await getTextOrNotFound(url, accept, options, false))!;
161
+ }
162
+
163
+ /** getText, or undefined for HTTP 404 when `notFoundIsEmpty` (an API's "no results"). */
164
+ async function getTextOrNotFound(
165
+ url: string,
166
+ accept: string,
167
+ options: Omit<FetchEcbOptions, 'history'> | undefined,
168
+ notFoundIsEmpty: boolean,
169
+ ): Promise<string | undefined> {
170
+ const fetchFunction = options?.fetch ?? (globalThis as { fetch?: Fetch }).fetch;
171
+ if (fetchFunction === undefined) {
172
+ throw new TypeError('No fetch available: pass options.fetch');
173
+ }
174
+ const init: { signal?: AbortSignalLike; headers: Record<string, string> } = {
175
+ headers: { accept },
176
+ };
177
+ const signal = requestSignal(options?.signal, options?.timeout ?? DEFAULT_TIMEOUT_MS);
178
+ if (signal !== undefined) {
179
+ init.signal = signal;
180
+ }
181
+ const response = await fetchFunction(url, init);
182
+ if (notFoundIsEmpty && response.status === 404) {
183
+ return undefined;
184
+ }
185
+ if (!response.ok) {
186
+ throw new FetchRatesError(url, response.status);
187
+ }
188
+ return response.text();
189
+ }
190
+
191
+ /**
192
+ * Fetches a date range of ECB euro reference rates from the ECB Data Portal and parses them with
193
+ * parseEcbSdmxCsv: EUR-based tables, one per TARGET working day, newest first, with the published
194
+ * digits. It makes two requests: the series' DECIMALS (small) and the observations without
195
+ * attributes, which keeps the full history since 1999 to about 15 MB. Give a few days before the
196
+ * first day you need, so that a lookback from it has a table to fall back on. A range with no
197
+ * rates (a weekend, or after the latest day) gives []. Both requests share one timeout and are
198
+ * cancelled together: if one fails, the other is aborted. The ECB states that its rates are for
199
+ * information purposes only.
200
+ *
201
+ * const tables = await fetchEcbRange({ start: '2026-01-01', currencies: ['USD', 'JPY'] });
202
+ * const history = rateHistory(tables); // from @ducatjs/money/convert/history
203
+ */
204
+ export async function fetchEcbRange(options: FetchEcbRangeOptions): Promise<RateTable[]> {
205
+ const currencies = options.currencies ?? [];
206
+ for (const currency of currencies) {
207
+ if (!/^[A-Z]{3}$/.test(currency)) {
208
+ throw new RangeError(`Not an ISO currency code: ${currency}`);
209
+ }
210
+ }
211
+ const start = requestDay(options.start, 'start');
212
+ if (start < '1999-01-04') {
213
+ throw new RangeError(`The ECB's rates start on 1999-01-04, not ${start}`);
214
+ }
215
+ let query = `?format=csvdata&startPeriod=${start}`;
216
+ if (options.end !== undefined) {
217
+ const end = requestDay(options.end, 'end');
218
+ if (end < start) {
219
+ throw new RangeError(`end ${end} is before start ${start}`);
220
+ }
221
+ query += `&endPeriod=${end}`;
222
+ }
223
+ const url = `${ECB_DATA_API}/D.${currencies.join('+')}.EUR.SP00.A`;
224
+ const seriesUrl = `${url}?format=csvdata&detail=nodata`;
225
+ const dataUrl = `${url}${query}&detail=dataonly`;
226
+
227
+ // One controller for both requests, tied to the caller's signal and the timeout, so that a
228
+ // failure of either cancels the other (the data can be ~15 MB) instead of letting it run on.
229
+ let shared: Omit<FetchEcbOptions, 'history'> = options;
230
+ let controller: AbortControllerLike | undefined;
231
+ const Controller = (globalThis as { AbortController?: new () => AbortControllerLike })
232
+ .AbortController;
233
+ if (Controller !== undefined) {
234
+ const own = new Controller();
235
+ controller = own;
236
+ const outer = requestSignal(options.signal, options.timeout ?? DEFAULT_TIMEOUT_MS);
237
+ if (outer?.aborted === true) {
238
+ own.abort(outer.reason);
239
+ } else {
240
+ outer?.addEventListener('abort', () => own.abort(outer.reason), { once: true });
241
+ }
242
+ shared = { ...options, signal: own.signal, timeout: 0 };
243
+ }
244
+
245
+ let series: string;
246
+ let data: string | undefined;
247
+ try {
248
+ [series, data] = await Promise.all([
249
+ getText(seriesUrl, 'text/csv', shared),
250
+ getTextOrNotFound(dataUrl, 'text/csv', shared, true),
251
+ ]);
252
+ } catch (error) {
253
+ controller?.abort(error);
254
+ throw error;
255
+ }
256
+ if (data === undefined) {
257
+ return [];
258
+ }
259
+ const decimals = parsedFrom(seriesUrl, () => parseEcbSdmxDecimals(series));
260
+ return parsedFrom(dataUrl, () => parseEcbSdmxCsv(data, { decimals }));
261
+ }
262
+
263
+ /** The Bank of Canada's Valet API for its daily exchange rates (no key; CORS-enabled). */
264
+ export const BANK_OF_CANADA_URL =
265
+ 'https://www.bankofcanada.ca/valet/observations/group/FX_RATES_DAILY/json';
266
+
267
+ /** Options for fetching Bank of Canada rates. */
268
+ export interface FetchBankOfCanadaOptions extends Omit<FetchEcbOptions, 'history'> {
269
+ /** The first day (2017-01-03 at the earliest). Default: only the latest published day. */
270
+ readonly start?: string | Date;
271
+ /** The last day (default: the latest published). Needs a `start`. */
272
+ readonly end?: string | Date;
273
+ }
274
+
275
+ /**
276
+ * Fetches the Bank of Canada's daily exchange rates from its Valet API and parses them with
277
+ * parseBankOfCanadaJson: CAD-based tables, newest first. Without `start`, only the latest day;
278
+ * without `end`, up to the latest. A range with no business days in it gives [].
279
+ * The Bank asks users to keep within its request limits, which it doesn't publish, and its rates
280
+ * are "released for statistical and analytical purposes only", not for transactions.
281
+ *
282
+ * const [latest] = await fetchBankOfCanada();
283
+ * const year = await fetchBankOfCanada({ start: '2026-01-01' });
284
+ */
285
+ export async function fetchBankOfCanada(options?: FetchBankOfCanadaOptions): Promise<RateTable[]> {
286
+ let query = '?recent=1';
287
+ if (options?.start !== undefined) {
288
+ const start = requestDay(options.start, 'start');
289
+ if (start < '2017-01-03') {
290
+ throw new RangeError(`Valet's daily rates start on 2017-01-03, not ${start}`);
291
+ }
292
+ query = `?start_date=${start}`;
293
+ if (options.end !== undefined) {
294
+ const end = requestDay(options.end, 'end');
295
+ if (end < start) {
296
+ throw new RangeError(`end ${end} is before start ${start}`);
297
+ }
298
+ query += `&end_date=${end}`;
299
+ }
300
+ } else if (options?.end !== undefined) {
301
+ throw new RangeError('end needs a start');
302
+ }
303
+ const url = `${BANK_OF_CANADA_URL}${query}`;
304
+ const body = await getText(url, 'application/json', options);
305
+ return parsedFrom(url, () => parseBankOfCanadaJson(body));
306
+ }
package/src/fn.ts CHANGED
@@ -12,6 +12,8 @@ export {
12
12
  zero,
13
13
  fromJSON,
14
14
  isMoney,
15
+ isMoneyIn,
16
+ assertCurrency,
15
17
  extendMoney,
16
18
  type ExtendMoneyOptions,
17
19
  sum,
@@ -26,6 +28,7 @@ export {
26
28
  toScale,
27
29
  compare,
28
30
  equals,
31
+ identical,
29
32
  lessThan,
30
33
  lessThanOrEqual,
31
34
  greaterThan,
@@ -37,10 +40,14 @@ export {
37
40
  min,
38
41
  max,
39
42
  clamp,
43
+ majorPart,
44
+ minorPart,
40
45
  toDecimal,
41
46
  toMinor,
42
47
  toMinorNumber,
43
48
  CurrencyMismatchError,
49
+ type RoundingPolicy,
50
+ type RoundingInput,
44
51
  type Money,
45
52
  type MoneyJSON,
46
53
  type MoneyOptions,
@@ -65,9 +72,16 @@ export * from './tax.js';
65
72
  export * from './convert.js';
66
73
  export * from './exact.js';
67
74
  export * from './ecb.js';
75
+ export * from './boc.js';
76
+ export * from './boe.js';
77
+ export * from './hmrc.js';
78
+ export * from './fed.js';
79
+ export * from './history.js';
68
80
  export * from './stripe-fx.js';
69
81
  export * from './adyen-fx.js';
70
82
  export * from './cash.js';
83
+ export * from './historic.js';
84
+ export { roundingPolicy, isoPolicy, assertScale, type RoundingPolicyOptions } from './policy.js';
71
85
  export * from './format.js';
72
86
  export * from './parse.js';
73
87
  export * from './stripe.js';