dsh-plugin-show-me-data 0.1.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 (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * 美国财政部「每日国债收益率曲线」adapter.
3
+ *
4
+ * This is the **official publisher** of the US Treasury constant-maturity
5
+ * yields that FRED's 'DGS*' series republish, so it is a drop-in replacement
6
+ * when FRED is unreachable (this deployment's egress IP is blocked by its edge
7
+ * WAF). One CSV per calendar year contains every tenor, so a single request can
8
+ * serve all of the rate indicators.
9
+ *
10
+ * Response shape (verified live):
11
+ *
12
+ * Date,"1 Mo","1.5 Month","2 Mo","3 Mo","4 Mo","6 Mo","1 Yr","2 Yr","3 Yr","5 Yr","7 Yr","10 Yr","20 Yr","30 Yr"
13
+ * 09/11/2026,3.93,3.99,4.05,4.07,4.15,4.12,4.35,4.63,4.69,4.78,4.87,4.96,5.38,5.35
14
+ *
15
+ * Traps this adapter contains:
16
+ * - dates are 'MM/DD/YYYY' and rows arrive **newest first**;
17
+ * - non-trading days simply have no row;
18
+ * - a tenor can be an empty string on days the Treasury did not publish it.
19
+ *
20
+ * @module sources/us-treasury-rates
21
+ */
22
+ import { SourceError } from '../core/types.js'
23
+ import { parseNumber } from '../core/stats/series.js'
24
+ import { getText } from './http.js'
25
+
26
+ /** @type {string} */
27
+ export const id = 'us-treasury-rates'
28
+
29
+ /** @type {string} */
30
+ export const label = '美国财政部·收益率曲线'
31
+
32
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
33
+ export const capabilities = [{ kinds: ['rates', 'bond'], frequencies: ['daily'] }]
34
+
35
+ /** Landing page for the dataset. */
36
+ const DATASET_PAGE = 'https://home.treasury.gov/resource-center/data-chart-center/interest-rates/TextView?type=daily_treasury_yield_curve'
37
+
38
+ /** The tenor column names, exactly as the CSV spells them. */
39
+ export const TENORS = [
40
+ '1 Mo',
41
+ '1.5 Month',
42
+ '2 Mo',
43
+ '3 Mo',
44
+ '4 Mo',
45
+ '6 Mo',
46
+ '1 Yr',
47
+ '2 Yr',
48
+ '3 Yr',
49
+ '5 Yr',
50
+ '7 Yr',
51
+ '10 Yr',
52
+ '20 Yr',
53
+ '30 Yr',
54
+ ]
55
+
56
+ /** Tenor labels → the catalog's 'params.tenor' spelling. */
57
+ export const TENOR_ALIASES = {
58
+ '1m': '1 Mo',
59
+ '1mo': '1 Mo',
60
+ '3m': '3 Mo',
61
+ '6m': '6 Mo',
62
+ '1y': '1 Yr',
63
+ '2y': '2 Yr',
64
+ '3y': '3 Yr',
65
+ '5y': '5 Yr',
66
+ '7y': '7 Yr',
67
+ '10y': '10 Yr',
68
+ '20y': '20 Yr',
69
+ '30y': '30 Yr',
70
+ }
71
+
72
+ /**
73
+ * Resolve a 'params.tenor' value to the CSV column name.
74
+ *
75
+ * @param {string|undefined} tenor - catalog tenor (e.g. '10y').
76
+ * @returns {string} CSV column name.
77
+ */
78
+ export function resolveTenor(tenor) {
79
+ if (typeof tenor !== 'string' || tenor === '') {
80
+ // A missing or unknown tenor is a *binding* mistake, not a data-shape
81
+ // problem: it can never succeed on retry, so it is classified `unsupported`.
82
+ throw new SourceError({
83
+ kind: 'unsupported',
84
+ adapterId: id,
85
+ seriesRef: 'daily_treasury_yield_curve',
86
+ detail: `params.tenor is required (one of ${Object.keys(TENOR_ALIASES).join(', ')})`,
87
+ })
88
+ }
89
+ const direct = TENORS.find((entry) => entry.toLowerCase() === tenor.toLowerCase())
90
+ if (direct !== undefined) return direct
91
+ const alias = TENOR_ALIASES[tenor.toLowerCase()]
92
+ if (alias !== undefined) return alias
93
+ throw new SourceError({
94
+ kind: 'unsupported',
95
+ adapterId: id,
96
+ seriesRef: 'daily_treasury_yield_curve',
97
+ detail: `unknown tenor ${JSON.stringify(tenor)}; known: ${Object.keys(TENOR_ALIASES).join(', ')}`,
98
+ })
99
+ }
100
+
101
+ /**
102
+ * Build the CSV URL for one calendar year.
103
+ *
104
+ * @param {number} year - calendar year.
105
+ * @returns {string} request URL.
106
+ */
107
+ export function buildUrl(year) {
108
+ return (
109
+ 'https://home.treasury.gov/resource-center/data-chart-center/interest-rates/daily-treasury-rates.csv/' +
110
+ `${year}/all?type=daily_treasury_yield_curve&field_tdr_date_value=${year}&page&_format=csv`
111
+ )
112
+ }
113
+
114
+ /**
115
+ * Source reference for one tenor.
116
+ *
117
+ * @param {string} seriesRef - dataset name ('daily_treasury_yield_curve').
118
+ * @param {{ tenor?: string }} [params] - catalog params.
119
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
120
+ */
121
+ export function sourceRef(seriesRef, params = {}) {
122
+ const tenor = params.tenor === undefined ? '10y' : String(params.tenor)
123
+ return {
124
+ adapterId: id,
125
+ url: DATASET_PAGE,
126
+ apiUrl: buildUrl(2026),
127
+ seriesRef: `${seriesRef}#${tenor}`,
128
+ label,
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Split one CSV line, honouring quoted fields.
134
+ *
135
+ * @param {string} line - CSV line.
136
+ * @returns {string[]} fields.
137
+ */
138
+ export function splitCsvLine(line) {
139
+ const fields = []
140
+ let current = ''
141
+ let quoted = false
142
+ for (let i = 0; i < line.length; i += 1) {
143
+ const char = line[i]
144
+ if (quoted) {
145
+ if (char === '"') {
146
+ if (line[i + 1] === '"') {
147
+ current += '"'
148
+ i += 1
149
+ } else quoted = false
150
+ } else current += char
151
+ } else if (char === '"') {
152
+ quoted = true
153
+ } else if (char === ',') {
154
+ fields.push(current)
155
+ current = ''
156
+ } else current += char
157
+ }
158
+ fields.push(current)
159
+ return fields.map((field) => field.trim())
160
+ }
161
+
162
+ /**
163
+ * Convert 'MM/DD/YYYY' into 'YYYY-MM-DD'.
164
+ *
165
+ * @param {string} value - raw date.
166
+ * @returns {string|undefined} normalized date.
167
+ */
168
+ export function normalizeDate(value) {
169
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(String(value ?? '').trim())
170
+ if (match === null) return undefined
171
+ const [, month, day, year] = match
172
+ return `${year}-${month}-${day}`
173
+ }
174
+
175
+ /**
176
+ * Parse one year's CSV into ascending points for one tenor.
177
+ *
178
+ * @param {string} body - CSV text.
179
+ * @param {string} column - tenor column name.
180
+ * @param {{ seriesRef?: string }} [options] - options.
181
+ * @returns {Array<{ t: string, v: number }>} ascending points.
182
+ */
183
+ export function parseCurve(body, column, { seriesRef = 'daily_treasury_yield_curve' } = {}) {
184
+ const lines = String(body ?? '')
185
+ .trim()
186
+ .split(/\r?\n/)
187
+ .filter((line) => line.trim() !== '')
188
+ if (lines.length === 0) {
189
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the CSV body is empty' })
190
+ }
191
+ const header = splitCsvLine(lines[0])
192
+ const index = header.indexOf(column)
193
+ if (index === -1) {
194
+ throw new SourceError({
195
+ kind: 'parse',
196
+ adapterId: id,
197
+ seriesRef,
198
+ detail: `column ${JSON.stringify(column)} is absent; header has ${header.join(' | ')}`,
199
+ })
200
+ }
201
+ const dateIndex = header.findIndex((entry) => entry.toLowerCase() === 'date')
202
+ if (dateIndex === -1) {
203
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'the header has no Date column' })
204
+ }
205
+
206
+ const points = []
207
+ for (const line of lines.slice(1)) {
208
+ const fields = splitCsvLine(line)
209
+ const date = normalizeDate(fields[dateIndex])
210
+ if (date === undefined) continue
211
+ const value = parseNumber(fields[index])
212
+ if (value === undefined) continue // an unpublished tenor is missing, not zero
213
+ points.push({ t: date, v: value })
214
+ }
215
+ if (points.length === 0) {
216
+ throw new SourceError({
217
+ kind: 'empty',
218
+ adapterId: id,
219
+ seriesRef,
220
+ detail: `no usable observations for ${column}`,
221
+ })
222
+ }
223
+ // The publisher lists newest first; every statistic downstream needs ascending.
224
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
225
+ return points
226
+ }
227
+
228
+ /**
229
+ * Fetch and normalize one tenor's series.
230
+ *
231
+ * @param {{ seriesRef: string, params?: { tenor?: string }, range?: { to?: string } }} req - request.
232
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
233
+ * @returns {Promise<object>} normalized 'RawSeries'.
234
+ */
235
+ export async function fetchSeries(req, deps) {
236
+ const { seriesRef, params } = req
237
+ const column = resolveTenor(params?.tenor)
238
+ // Three calendar years cover every range preset (5Y/Max fall back to the
239
+ // oldest year available) without downloading the full archive.
240
+ const endYear = Number(String(req.range?.to ?? '').slice(0, 4))
241
+ const currentYear = Number.isInteger(endYear) && endYear > 1980 ? endYear : deps.clock.now().getUTCFullYear()
242
+ const years = [currentYear, currentYear - 1, currentYear - 2]
243
+ const bodies = await Promise.all(
244
+ years.map(async (year) => {
245
+ const { body } = await getText({
246
+ fetch: deps.fetch,
247
+ url: buildUrl(year),
248
+ adapterId: id,
249
+ seriesRef,
250
+ signal: deps.signal,
251
+ allowHttpError: true,
252
+ })
253
+ return body
254
+ }),
255
+ )
256
+ const merged = new Map()
257
+ for (const body of bodies) {
258
+ for (const point of parseCurve(body, column, { seriesRef })) merged.set(point.t, point.v)
259
+ }
260
+ const points = [...merged.entries()]
261
+ .map(([t, v]) => ({ t, v }))
262
+ .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
263
+ if (points.length === 0) {
264
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: `no observations for ${column} in ${years.join('/')}` })
265
+ }
266
+ return {
267
+ adapterId: id,
268
+ seriesRef,
269
+ points,
270
+ meta: { name: `US Treasury ${column}`, unit: '%', freq: 'daily' },
271
+ fetchedAt: deps.clock.now().toISOString(),
272
+ sourceRef: sourceRef(seriesRef, params),
273
+ raw: { years, column, points: points.length },
274
+ }
275
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * 美国财政部 fiscaldata adapter (docs/04 §4).
3
+ *
4
+ * The response is a JSON envelope whose 'data' holds one row per
5
+ * 'security_desc' **per month**, and every numeric field is a *string* that may
6
+ * be empty. Without a 'filter', a series mixes Bills/Notes/Bonds/TIPS together —
7
+ * so the catalog passes 'params.filter' for a single security type.
8
+ *
9
+ * @module sources/us-treasury
10
+ */
11
+ import { SourceError } from '../core/types.js'
12
+ import { parseNumber } from '../core/stats/series.js'
13
+ import { getText, parseJson } from './http.js'
14
+
15
+ /** @type {string} */
16
+ export const id = 'us-treasury'
17
+
18
+ /** @type {string} */
19
+ export const label = '美国财政部'
20
+
21
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
22
+ export const capabilities = [{ kinds: ['rates', 'fiscal'], frequencies: ['monthly'] }]
23
+
24
+ /** Dataset endpoints this adapter serves. */
25
+ export const DATASETS = {
26
+ avg_interest_rates: 'v2/accounting/od/avg_interest_rates',
27
+ }
28
+
29
+ /** Field holding the observation date. */
30
+ export const DATE_FIELD = 'record_date'
31
+
32
+ /**
33
+ * Build a fiscaldata URL.
34
+ *
35
+ * @param {string} dataset - dataset key (see {@link DATASETS}).
36
+ * @param {{ filter?: string, pageSize?: number, sort?: string }} [options] - query options.
37
+ * @returns {string} request URL.
38
+ */
39
+ export function buildUrl(dataset, { filter, pageSize = 500, sort = `-${DATE_FIELD}` } = {}) {
40
+ const path = DATASETS[dataset]
41
+ if (path === undefined) {
42
+ throw new SourceError({
43
+ kind: 'unsupported',
44
+ adapterId: id,
45
+ seriesRef: dataset,
46
+ detail: `unknown fiscaldata dataset ${JSON.stringify(dataset)}; known: ${Object.keys(DATASETS).join(', ')}`,
47
+ })
48
+ }
49
+ const query = [`page[size]=${pageSize}`, `sort=${sort}`]
50
+ // fiscaldata expects the filter expression URL-encoded (`:` → `%3A`, space → `%20`).
51
+ if (filter) query.push(`filter=${encodeURIComponent(filter)}`)
52
+ return `https://api.fiscaldata.treasury.gov/services/api/fiscal_service/${path}?${query.join('&')}`
53
+ }
54
+
55
+ /**
56
+ * Dataset landing page plus the API URL.
57
+ *
58
+ * @param {string} seriesRef - dataset key.
59
+ * @param {{ filter?: string, valueField?: string }} [params] - catalog params.
60
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
61
+ */
62
+ export function sourceRef(seriesRef, params = {}) {
63
+ const valueField = params.valueField ?? 'avg_interest_rate_amt'
64
+ // Provenance must never throw: an unknown dataset still has to produce a
65
+ // clickable landing page (the fetch path is where the id is rejected).
66
+ const apiUrl = DATASETS[seriesRef] === undefined
67
+ ? 'https://fiscaldata.treasury.gov/datasets/'
68
+ : buildUrl(seriesRef, { filter: params.filter })
69
+ return {
70
+ adapterId: id,
71
+ url: 'https://fiscaldata.treasury.gov/datasets/average-interest-rates-treasury-securities/',
72
+ apiUrl,
73
+ seriesRef: params.filter ? `${seriesRef}#${params.filter}` : `${seriesRef}#${valueField}`,
74
+ label,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse a fiscaldata payload.
80
+ *
81
+ * @param {string} body - response body.
82
+ * @param {string} seriesRef - dataset key (for errors).
83
+ * @param {{ valueField?: string }} [params] - which numeric column to read.
84
+ * @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
85
+ */
86
+ export function parseDataset(body, seriesRef, params = {}) {
87
+ const valueField = params.valueField ?? 'avg_interest_rate_amt'
88
+ const payload = parseJson(body, id, seriesRef)
89
+ if (payload === null || typeof payload !== 'object' || !Array.isArray(payload.data)) {
90
+ throw new SourceError({
91
+ kind: 'parse',
92
+ adapterId: id,
93
+ seriesRef,
94
+ detail: 'response has no `data` array',
95
+ })
96
+ }
97
+ if (payload.data.length === 0) {
98
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the dataset returned zero rows' })
99
+ }
100
+ const points = []
101
+ let sawField = false
102
+ const byDate = new Map()
103
+ for (const row of payload.data) {
104
+ if (row === null || typeof row !== 'object') continue
105
+ if (valueField in row) sawField = true
106
+ const date = row[DATE_FIELD]
107
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
108
+ const value = parseNumber(row[valueField])
109
+ if (value === undefined) continue
110
+ // One row per security type: keep the last read for a date so a filtered
111
+ // series is exact and an unfiltered one is at least deterministic.
112
+ byDate.set(date, value)
113
+ }
114
+ if (!sawField) {
115
+ throw new SourceError({
116
+ kind: 'parse',
117
+ adapterId: id,
118
+ seriesRef,
119
+ detail: `field ${valueField} is absent from the dataset; available: ${Object.keys(payload.data[0]).slice(0, 10).join(', ')}`,
120
+ })
121
+ }
122
+ for (const [t, v] of byDate) points.push({ t, v })
123
+ if (points.length === 0) {
124
+ throw new SourceError({
125
+ kind: 'empty',
126
+ adapterId: id,
127
+ seriesRef,
128
+ detail: 'every row was missing a usable date or value',
129
+ })
130
+ }
131
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
132
+ return { name: params.filter ? `${seriesRef} (${params.filter})` : seriesRef, points }
133
+ }
134
+
135
+ /**
136
+ * Fetch and normalize one fiscaldata series.
137
+ *
138
+ * @param {{ seriesRef: string, params?: { filter?: string, valueField?: string }, range?: object }} req - request.
139
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
140
+ * @returns {Promise<object>} normalized 'RawSeries'.
141
+ */
142
+ export async function fetchSeries(req, deps) {
143
+ const { seriesRef, params } = req
144
+ const url = buildUrl(seriesRef, { filter: params?.filter })
145
+ const { status, body } = await getText({
146
+ fetch: deps.fetch,
147
+ url,
148
+ adapterId: id,
149
+ seriesRef,
150
+ signal: deps.signal,
151
+ allowHttpError: true,
152
+ })
153
+ if (status >= 400 && status < 500) {
154
+ // fiscaldata answers an unknown dataset with an HTML 404 page.
155
+ throw new SourceError({
156
+ kind: 'unsupported',
157
+ adapterId: id,
158
+ seriesRef,
159
+ detail: `fiscaldata returned HTTP ${status} for dataset ${seriesRef}`,
160
+ httpStatus: status,
161
+ })
162
+ }
163
+ if (status >= 500) {
164
+ throw new SourceError({
165
+ kind: 'http',
166
+ adapterId: id,
167
+ seriesRef,
168
+ detail: `fiscaldata is unavailable (HTTP ${status})`,
169
+ httpStatus: status,
170
+ })
171
+ }
172
+ const { name, points } = parseDataset(body, seriesRef, params ?? {})
173
+ return {
174
+ adapterId: id,
175
+ seriesRef,
176
+ points,
177
+ meta: { name, unit: '%', freq: 'monthly' },
178
+ fetchedAt: deps.clock.now().toISOString(),
179
+ sourceRef: sourceRef(seriesRef, params),
180
+ raw: { url, httpStatus: status, rows: payloadRowCount(body) },
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Row count of a payload, tolerant of a malformed body (used for 'raw' only).
186
+ *
187
+ * @param {string} body - response body.
188
+ * @returns {number} row count or 0.
189
+ */
190
+ function payloadRowCount(body) {
191
+ try {
192
+ return JSON.parse(body)?.data?.length ?? 0
193
+ } catch {
194
+ return 0
195
+ }
196
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * World Bank adapter (docs/04 §5).
3
+ *
4
+ * The response is a **two-element array** '[meta, rows]', and 'rows' is 'null'
5
+ * (not '[]') when the query has no data. Annual values carry only a year, which
6
+ * is normalized to December 31 of that year — inventing '-01-01' would claim a
7
+ * precision the source does not have.
8
+ *
9
+ * @module sources/worldbank
10
+ */
11
+ import { SourceError } from '../core/types.js'
12
+ import { parseNumber } from '../core/stats/series.js'
13
+ import { getText, parseJson } from './http.js'
14
+
15
+ /** @type {string} */
16
+ export const id = 'worldbank'
17
+
18
+ /** @type {string} */
19
+ export const label = 'World Bank'
20
+
21
+ /**
22
+ * The World Bank answers in 0.4–14s from this network, well past the default 6s
23
+ * per-source budget, so it declares its own (see 'sources/registry.sourceTimeoutMs').
24
+ */
25
+ export const timeoutMs = 15_000
26
+
27
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
28
+ export const capabilities = [{ kinds: ['macro'], frequencies: ['annual'] }]
29
+
30
+ /** How far back the adapter asks for, so a 5-year window always has history. */
31
+ const DEFAULT_START_YEAR = '2000'
32
+
33
+ /**
34
+ * Open-ended end of the requested span. '3000' keeps the query deterministic
35
+ * (no clock read) while still meaning "everything published".
36
+ */
37
+ const OPEN_END_YEAR = '3000'
38
+
39
+ /**
40
+ * Build an indicator URL.
41
+ *
42
+ * @param {string} seriesRef - indicator id, e.g. 'NY.GDP.MKTP.CD'.
43
+ * @param {{ country?: string, date?: string, perPage?: number }} [params] - catalog params.
44
+ * @returns {string} request URL.
45
+ */
46
+ export function buildUrl(seriesRef, { country = 'US', date = `${DEFAULT_START_YEAR}:${OPEN_END_YEAR}`, perPage = 100 } = {}) {
47
+ return (
48
+ `https://api.worldbank.org/v2/country/${encodeURIComponent(country)}` +
49
+ `/indicator/${encodeURIComponent(seriesRef)}` +
50
+ `?format=json&per_page=${perPage}&date=${date}`
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Indicator landing page plus the API URL.
56
+ *
57
+ * @param {string} seriesRef - indicator id.
58
+ * @param {{ country?: string }} [params] - catalog params.
59
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
60
+ */
61
+ export function sourceRef(seriesRef, params = {}) {
62
+ const country = params.country ?? 'US'
63
+ return {
64
+ adapterId: id,
65
+ url: `https://data.worldbank.org/indicator/${encodeURIComponent(seriesRef)}?locations=${encodeURIComponent(country)}`,
66
+ apiUrl: buildUrl(seriesRef, { country }),
67
+ seriesRef: `${seriesRef}@${country}`,
68
+ label,
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Parse a World Bank payload.
74
+ *
75
+ * @param {string} body - response body.
76
+ * @param {string} seriesRef - indicator id (for errors).
77
+ * @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
78
+ */
79
+ export function parseIndicator(body, seriesRef) {
80
+ const payload = parseJson(body, id, seriesRef)
81
+ if (!Array.isArray(payload)) {
82
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'response is not the [meta, rows] pair' })
83
+ }
84
+ const rows = payload[1]
85
+ if (rows === null || rows === undefined) {
86
+ // The documented "no data" shape: `[ {message: [...]}, null ]`
87
+ const message = payload[0]?.message?.[0]?.value
88
+ throw new SourceError({
89
+ kind: 'empty',
90
+ adapterId: id,
91
+ seriesRef,
92
+ detail: `World Bank returned no rows for this query${message ? ` (${message})` : ''}`,
93
+ })
94
+ }
95
+ if (!Array.isArray(rows)) {
96
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'the rows element is not an array' })
97
+ }
98
+ if (rows.length === 0) {
99
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the rows array is empty' })
100
+ }
101
+ const points = []
102
+ let name = seriesRef
103
+ for (const row of rows) {
104
+ if (row === null || typeof row !== 'object') continue
105
+ if (row.indicator?.value) name = row.indicator.value
106
+ const year = Number(row.date)
107
+ const value = parseNumber(row.value)
108
+ if (!Number.isInteger(year) || value === undefined) continue
109
+ // Annual observation: date it at the end of its year.
110
+ points.push({ t: `${String(year).padStart(4, '0')}-12-31`, v: value })
111
+ }
112
+ if (points.length === 0) {
113
+ throw new SourceError({
114
+ kind: 'empty',
115
+ adapterId: id,
116
+ seriesRef,
117
+ detail: 'every row had a null year or value',
118
+ })
119
+ }
120
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
121
+ return { name, points }
122
+ }
123
+
124
+ /**
125
+ * Fetch and normalize one indicator.
126
+ *
127
+ * @param {{ seriesRef: string, params?: { country?: string, date?: string } }} req - request.
128
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
129
+ * @returns {Promise<object>} normalized 'RawSeries'.
130
+ */
131
+ export async function fetchSeries(req, deps) {
132
+ const { seriesRef, params } = req
133
+ const url = buildUrl(seriesRef, params ?? {})
134
+ const { status, body } = await getText({
135
+ fetch: deps.fetch,
136
+ url,
137
+ adapterId: id,
138
+ seriesRef,
139
+ signal: deps.signal,
140
+ allowHttpError: true,
141
+ })
142
+ if (status >= 400 && status < 500) {
143
+ throw new SourceError({
144
+ kind: 'unsupported',
145
+ adapterId: id,
146
+ seriesRef,
147
+ detail: `World Bank returned HTTP ${status} for ${seriesRef}; the indicator or country is not valid`,
148
+ httpStatus: status,
149
+ })
150
+ }
151
+ if (status >= 500) {
152
+ throw new SourceError({
153
+ kind: 'http',
154
+ adapterId: id,
155
+ seriesRef,
156
+ detail: `World Bank is unavailable (HTTP ${status})`,
157
+ httpStatus: status,
158
+ })
159
+ }
160
+ const { name, points } = parseIndicator(body, seriesRef)
161
+ return {
162
+ adapterId: id,
163
+ seriesRef,
164
+ points,
165
+ meta: { name, unit: undefined, freq: 'annual' },
166
+ fetchedAt: deps.clock.now().toISOString(),
167
+ sourceRef: sourceRef(seriesRef, params),
168
+ raw: { url, httpStatus: status, rows: points.length },
169
+ }
170
+ }