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.
- package/LICENSE +27 -0
- package/README.md +96 -0
- package/cordis.patch.yml +40 -0
- package/docs/01-product-effect.md +178 -0
- package/docs/02-architecture.md +275 -0
- package/docs/03-data-contracts.md +291 -0
- package/docs/04-sources.md +342 -0
- package/docs/05-ui-spec.md +167 -0
- package/docs/06-ai-layer.md +194 -0
- package/docs/07-implementation-plan.md +399 -0
- package/docs/08-test-plan.md +133 -0
- package/docs/09-packaging-install.md +249 -0
- package/docs/10-kickoff-prompt.md +94 -0
- package/docs/11-decisions.md +203 -0
- package/docs/12-runtime-verified.md +115 -0
- package/docs/13-acceptance.md +153 -0
- package/docs/14-progress.md +150 -0
- package/docs/15-publish.md +185 -0
- package/lib/app/ai-deterministic.js +327 -0
- package/lib/app/ai-validate.js +284 -0
- package/lib/app/ai.js +440 -0
- package/lib/app/health.js +77 -0
- package/lib/app/overview.js +349 -0
- package/lib/app/propose-indicator.js +122 -0
- package/lib/app/refresh.js +251 -0
- package/lib/app/series-view.js +195 -0
- package/lib/app/watchlist.js +102 -0
- package/lib/client.js +4322 -0
- package/lib/core/ai/prompts.js +213 -0
- package/lib/core/chart/axis.js +133 -0
- package/lib/core/chart/bar.js +58 -0
- package/lib/core/chart/candle.js +216 -0
- package/lib/core/chart/line.js +186 -0
- package/lib/core/chart/scale.js +132 -0
- package/lib/core/format.js +143 -0
- package/lib/core/indicators/catalog.js +1011 -0
- package/lib/core/indicators/resolve.js +196 -0
- package/lib/core/insight/digest.js +250 -0
- package/lib/core/insight/rank.js +115 -0
- package/lib/core/insight/related.js +90 -0
- package/lib/core/insight/rules.js +417 -0
- package/lib/core/stats/derive.js +123 -0
- package/lib/core/stats/series.js +465 -0
- package/lib/core/time/range.js +242 -0
- package/lib/core/types.js +478 -0
- package/lib/host/ai/discussion.js +559 -0
- package/lib/host/ai/dsh-llm-gateway.js +333 -0
- package/lib/host/config.js +194 -0
- package/lib/host/http/respond.js +165 -0
- package/lib/host/http/routes.js +689 -0
- package/lib/host/index.js +293 -0
- package/lib/host/infra/fs-repos.js +179 -0
- package/lib/host/infra/memory-fallback.js +64 -0
- package/lib/host/tools/define-tool.js +295 -0
- package/lib/host/tools/register.js +431 -0
- package/lib/host.js +7 -0
- package/lib/ports/clock.js +57 -0
- package/lib/ports/snapshot-repo.js +48 -0
- package/lib/sources/eastmoney-macro.js +197 -0
- package/lib/sources/eastmoney-quote.js +201 -0
- package/lib/sources/ecb.js +179 -0
- package/lib/sources/fred.js +207 -0
- package/lib/sources/http.js +136 -0
- package/lib/sources/ohlc.js +36 -0
- package/lib/sources/quote-cascade.js +177 -0
- package/lib/sources/registry.js +153 -0
- package/lib/sources/sina-cn.js +197 -0
- package/lib/sources/sina-us.js +187 -0
- package/lib/sources/tencent.js +158 -0
- package/lib/sources/us-treasury-rates.js +275 -0
- package/lib/sources/us-treasury.js +196 -0
- package/lib/sources/worldbank.js +170 -0
- package/package.json +69 -0
- package/src/app/ai-deterministic.js +327 -0
- package/src/app/ai-validate.js +284 -0
- package/src/app/ai.js +440 -0
- package/src/app/health.js +77 -0
- package/src/app/overview.js +349 -0
- package/src/app/propose-indicator.js +122 -0
- package/src/app/refresh.js +251 -0
- package/src/app/series-view.js +195 -0
- package/src/app/watchlist.js +102 -0
- package/src/client/api.js +323 -0
- package/src/client/components.js +1877 -0
- package/src/client/copy.js +368 -0
- package/src/client/index.js +169 -0
- package/src/client/store.js +219 -0
- package/src/core/ai/prompts.js +213 -0
- package/src/core/chart/axis.js +133 -0
- package/src/core/chart/bar.js +58 -0
- package/src/core/chart/candle.js +216 -0
- package/src/core/chart/line.js +186 -0
- package/src/core/chart/scale.js +132 -0
- package/src/core/format.js +143 -0
- package/src/core/indicators/catalog.js +1011 -0
- package/src/core/indicators/resolve.js +196 -0
- package/src/core/insight/digest.js +250 -0
- package/src/core/insight/rank.js +115 -0
- package/src/core/insight/related.js +90 -0
- package/src/core/insight/rules.js +417 -0
- package/src/core/stats/derive.js +123 -0
- package/src/core/stats/series.js +465 -0
- package/src/core/time/range.js +242 -0
- package/src/core/types.js +478 -0
- package/src/host/ai/discussion.js +559 -0
- package/src/host/ai/dsh-llm-gateway.js +333 -0
- package/src/host/config.js +194 -0
- package/src/host/http/respond.js +165 -0
- package/src/host/http/routes.js +689 -0
- package/src/host/index.js +293 -0
- package/src/host/infra/fs-repos.js +179 -0
- package/src/host/infra/memory-fallback.js +64 -0
- package/src/host/tools/define-tool.js +295 -0
- package/src/host/tools/register.js +431 -0
- package/src/ports/clock.js +57 -0
- package/src/ports/snapshot-repo.js +48 -0
- package/src/sources/eastmoney-macro.js +197 -0
- package/src/sources/eastmoney-quote.js +201 -0
- package/src/sources/ecb.js +179 -0
- package/src/sources/fred.js +207 -0
- package/src/sources/http.js +136 -0
- package/src/sources/ohlc.js +36 -0
- package/src/sources/quote-cascade.js +177 -0
- package/src/sources/registry.js +153 -0
- package/src/sources/sina-cn.js +197 -0
- package/src/sources/sina-us.js +187 -0
- package/src/sources/tencent.js +158 -0
- package/src/sources/us-treasury-rates.js +275 -0
- package/src/sources/us-treasury.js +196 -0
- 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
|
+
}
|