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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 东方财富数据中心 adapter — Chinese macro reports (docs/04 §3).
|
|
3
|
+
*
|
|
4
|
+
* Two traps live here and both are covered by tests:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Column naming is inverted for money supply**: in
|
|
7
|
+
* 'RPT_ECONOMY_CURRENCY_SUPPLY', 'BASIC_CURRENCY' is M2 (≈3.55 quadrillion)
|
|
8
|
+
* and 'CURRENCY' is M1 (≈1.15 quadrillion) — the opposite of intuition. The
|
|
9
|
+
* catalog must always name the column explicitly ('params.column').
|
|
10
|
+
* 2. **Rows arrive newest-first** and 'success:false' still returns HTTP 200,
|
|
11
|
+
* so the body — not the status — decides.
|
|
12
|
+
*
|
|
13
|
+
* @module sources/eastmoney-macro
|
|
14
|
+
*/
|
|
15
|
+
import { SourceError } from '../core/types.js'
|
|
16
|
+
import { parseNumber } from '../core/stats/series.js'
|
|
17
|
+
import { getText, parseJson } from './http.js'
|
|
18
|
+
|
|
19
|
+
/** @type {string} */
|
|
20
|
+
export const id = 'eastmoney-macro'
|
|
21
|
+
|
|
22
|
+
/** @type {string} */
|
|
23
|
+
export const label = '东方财富·数据中心'
|
|
24
|
+
|
|
25
|
+
/** @type {Array<{ kinds: string[], frequencies: string[] }>} */
|
|
26
|
+
export const capabilities = [{ kinds: ['macro'], frequencies: ['monthly', 'quarterly'] }]
|
|
27
|
+
|
|
28
|
+
/** Landing page per report, for the source badge (docs/04 §3). */
|
|
29
|
+
const REPORT_PAGES = {
|
|
30
|
+
RPT_ECONOMY_CPI: 'https://data.eastmoney.com/cjsj/cpi.html',
|
|
31
|
+
RPT_ECONOMY_PPI: 'https://data.eastmoney.com/cjsj/ppi.html',
|
|
32
|
+
RPT_ECONOMY_PMI: 'https://data.eastmoney.com/cjsj/pmi.html',
|
|
33
|
+
RPT_ECONOMY_GDP: 'https://data.eastmoney.com/cjsj/gdp.html',
|
|
34
|
+
RPT_ECONOMY_CURRENCY_SUPPLY: 'https://data.eastmoney.com/cjsj/hbgyl.html',
|
|
35
|
+
// Customs publishes both a year-on-year percentage and the level; the level is
|
|
36
|
+
// what a chart needs, the percentage is only a cross-check.
|
|
37
|
+
RPT_ECONOMY_CUSTOMS: 'https://data.eastmoney.com/cjsj/hgjck.html',
|
|
38
|
+
// The 70-city house price survey: this report is **per city**, so the series
|
|
39
|
+
// identity includes the city, not just the column.
|
|
40
|
+
RPT_ECONOMY_HOUSE_PRICE: 'https://data.eastmoney.com/cjsj/newhouse.html',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the datacenter URL for one report.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} reportName - report id.
|
|
47
|
+
* @param {{ pageSize?: number, pageNumber?: number }} [options] - paging.
|
|
48
|
+
* @returns {string} request URL.
|
|
49
|
+
*/
|
|
50
|
+
export function buildUrl(reportName, { pageSize = 200, pageNumber = 1, city } = {}) {
|
|
51
|
+
// A per-city report needs the filter or it returns every city interleaved,
|
|
52
|
+
// which would look like one series whose value jumps between cities.
|
|
53
|
+
const filter = typeof city === 'string' && city !== '' ? `&filter=${encodeURIComponent(`(CITY="${city}")`)}` : ''
|
|
54
|
+
return (
|
|
55
|
+
'https://datacenter-web.eastmoney.com/api/data/v1/get' +
|
|
56
|
+
`?reportName=${encodeURIComponent(reportName)}` +
|
|
57
|
+
'&columns=ALL' +
|
|
58
|
+
`&pageSize=${pageSize}&pageNumber=${pageNumber}` +
|
|
59
|
+
'&sortColumns=REPORT_DATE&sortTypes=-1' +
|
|
60
|
+
'&source=WEB&client=WEB' +
|
|
61
|
+
filter
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Landing page plus the data API URL.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} seriesRef - report name.
|
|
69
|
+
* @param {{ column?: string, city?: string }} [params] - catalog params (column and city are part of the identity).
|
|
70
|
+
* @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
|
|
71
|
+
*/
|
|
72
|
+
export function sourceRef(seriesRef, params = {}) {
|
|
73
|
+
const identity = [params.column, params.city].filter((part) => typeof part === 'string' && part !== '').join(':')
|
|
74
|
+
return {
|
|
75
|
+
adapterId: id,
|
|
76
|
+
url: REPORT_PAGES[seriesRef] ?? 'https://data.eastmoney.com/cjsj/',
|
|
77
|
+
apiUrl: buildUrl(seriesRef, { city: params.city }),
|
|
78
|
+
seriesRef: identity === '' ? seriesRef : `${seriesRef}:${identity}`,
|
|
79
|
+
label,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse a datacenter payload into ascending points for one column.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} body - response body.
|
|
87
|
+
* @param {string} seriesRef - report name (for errors).
|
|
88
|
+
* @param {string} column - column to read as 'v'.
|
|
89
|
+
* @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
|
|
90
|
+
*/
|
|
91
|
+
export function parseReport(body, seriesRef, column) {
|
|
92
|
+
if (!column) {
|
|
93
|
+
throw new SourceError({
|
|
94
|
+
kind: 'parse',
|
|
95
|
+
adapterId: id,
|
|
96
|
+
seriesRef,
|
|
97
|
+
detail: 'params.column is required: one report serves several indicators',
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
const payload = parseJson(body, id, seriesRef)
|
|
101
|
+
if (payload === null || typeof payload !== 'object') {
|
|
102
|
+
throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'response is not a JSON object' })
|
|
103
|
+
}
|
|
104
|
+
if (payload.success === false) {
|
|
105
|
+
// Business failure on HTTP 200: the report does not exist (code 9501) or the
|
|
106
|
+
// request is malformed. Never retryable.
|
|
107
|
+
throw new SourceError({
|
|
108
|
+
kind: 'unsupported',
|
|
109
|
+
adapterId: id,
|
|
110
|
+
seriesRef,
|
|
111
|
+
detail: `datacenter rejected report ${seriesRef}: ${payload.message ?? 'unknown reason'} (code ${payload.code ?? '?'})`,
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
const rows = payload.result?.data
|
|
115
|
+
if (!Array.isArray(rows)) {
|
|
116
|
+
throw new SourceError({
|
|
117
|
+
kind: 'parse',
|
|
118
|
+
adapterId: id,
|
|
119
|
+
seriesRef,
|
|
120
|
+
detail: 'result.data is missing or not an array',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
if (rows.length === 0) {
|
|
124
|
+
throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the report returned zero rows' })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const points = []
|
|
128
|
+
let sawColumn = false
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
if (row === null || typeof row !== 'object') continue
|
|
131
|
+
const rawDate = row.REPORT_DATE
|
|
132
|
+
if (typeof rawDate !== 'string' || rawDate.length < 10) {
|
|
133
|
+
throw new SourceError({
|
|
134
|
+
kind: 'parse',
|
|
135
|
+
adapterId: id,
|
|
136
|
+
seriesRef,
|
|
137
|
+
detail: `row is missing a usable REPORT_DATE (${JSON.stringify(rawDate)})`,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
if (column in row) sawColumn = true
|
|
141
|
+
const value = parseNumber(row[column])
|
|
142
|
+
if (value === undefined) continue
|
|
143
|
+
points.push({ t: rawDate.slice(0, 10), v: value })
|
|
144
|
+
}
|
|
145
|
+
if (!sawColumn) {
|
|
146
|
+
// A silent `undefined` per row is exactly the failure mode docs/04 §3 warns
|
|
147
|
+
// about: fail loudly instead of publishing an empty series.
|
|
148
|
+
throw new SourceError({
|
|
149
|
+
kind: 'parse',
|
|
150
|
+
adapterId: id,
|
|
151
|
+
seriesRef,
|
|
152
|
+
detail: `column ${column} does not exist in report ${seriesRef}; available: ${Object.keys(rows[0]).slice(0, 12).join(', ')}`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
if (points.length === 0) {
|
|
156
|
+
throw new SourceError({
|
|
157
|
+
kind: 'empty',
|
|
158
|
+
adapterId: id,
|
|
159
|
+
seriesRef,
|
|
160
|
+
detail: `column ${column} exists but every row was empty or unparseable`,
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
// The upstream sorts descending; statistics require ascending.
|
|
164
|
+
points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
|
|
165
|
+
return { name: `${seriesRef}:${column}`, points }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch and normalize one macro series.
|
|
170
|
+
*
|
|
171
|
+
* @param {{ seriesRef: string, params?: { column?: string, city?: string }, range?: object, pageSize?: number }} req - request.
|
|
172
|
+
* @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
|
|
173
|
+
* @returns {Promise<object>} normalized 'RawSeries'.
|
|
174
|
+
*/
|
|
175
|
+
export async function fetchSeries(req, deps) {
|
|
176
|
+
const { seriesRef, params } = req
|
|
177
|
+
const column = params?.column
|
|
178
|
+
const city = params?.city
|
|
179
|
+
const url = buildUrl(seriesRef, { pageSize: req.pageSize ?? 200, city })
|
|
180
|
+
const { status, body } = await getText({
|
|
181
|
+
fetch: deps.fetch,
|
|
182
|
+
url,
|
|
183
|
+
adapterId: id,
|
|
184
|
+
seriesRef,
|
|
185
|
+
signal: deps.signal,
|
|
186
|
+
})
|
|
187
|
+
const { name, points } = parseReport(body, seriesRef, column)
|
|
188
|
+
return {
|
|
189
|
+
adapterId: id,
|
|
190
|
+
seriesRef,
|
|
191
|
+
points,
|
|
192
|
+
meta: { name, unit: undefined, freq: 'monthly' },
|
|
193
|
+
fetchedAt: deps.clock.now().toISOString(),
|
|
194
|
+
sourceRef: sourceRef(seriesRef, { column, city }),
|
|
195
|
+
raw: { url, httpStatus: status, rows: points.length, column, city },
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 东方财富行情 adapter — indices and bond indices from 'push2his' (docs/04 §2).
|
|
3
|
+
*
|
|
4
|
+
* The response is a JSON envelope whose 'data.klines' is a list of comma-joined
|
|
5
|
+
* CSV rows whose column order is exactly the order of the requested 'fields2'.
|
|
6
|
+
* We always request 'f51'(date) … 'f53'(close), which is why the close is the
|
|
7
|
+
* **third** column — the trap this adapter exists to contain.
|
|
8
|
+
*
|
|
9
|
+
* @module sources/eastmoney-quote
|
|
10
|
+
*/
|
|
11
|
+
import { SourceError } from '../core/types.js'
|
|
12
|
+
import { addMonths, daysBetween } from '../core/time/range.js'
|
|
13
|
+
import { parseNumber } from '../core/stats/series.js'
|
|
14
|
+
import { withBar } from './ohlc.js'
|
|
15
|
+
import { getText, parseJson } from './http.js'
|
|
16
|
+
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
export const id = 'eastmoney-quote'
|
|
19
|
+
|
|
20
|
+
/** @type {string} */
|
|
21
|
+
export const label = '东方财富'
|
|
22
|
+
|
|
23
|
+
/** @type {Array<{ kinds: string[], frequencies: string[] }>} */
|
|
24
|
+
export const capabilities = [{ kinds: ['equity', 'bond'], frequencies: ['daily'] }]
|
|
25
|
+
|
|
26
|
+
/** Its endpoint takes 'klt', so day/week/month bars are all real. */
|
|
27
|
+
export const barSizes = true
|
|
28
|
+
|
|
29
|
+
/** The field list this adapter requests, in order. */
|
|
30
|
+
export const FIELDS2 = ['f51', 'f52', 'f53', 'f54', 'f55', 'f56', 'f57', 'f58', 'f59', 'f60', 'f61']
|
|
31
|
+
|
|
32
|
+
/** Index of the close price inside the requested field list ('f53'). */
|
|
33
|
+
export const CLOSE_FIELD_INDEX = FIELDS2.indexOf('f53')
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Column indices of one OHLC bar inside the requested field list.
|
|
37
|
+
*
|
|
38
|
+
* 'fields2' already asks for f51..f55, so open/high/low arrive with every kline
|
|
39
|
+
* row; the adapter used to read only the close and drop the rest.
|
|
40
|
+
*/
|
|
41
|
+
export const OHLC_FIELD_INDEX = {
|
|
42
|
+
date: FIELDS2.indexOf('f51'),
|
|
43
|
+
open: FIELDS2.indexOf('f52'),
|
|
44
|
+
close: FIELDS2.indexOf('f53'),
|
|
45
|
+
high: FIELDS2.indexOf('f54'),
|
|
46
|
+
low: FIELDS2.indexOf('f55'),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default bars per request; enough for a 5-year window without a slow response. */
|
|
50
|
+
const DEFAULT_LIMIT = 1600
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the kline URL for one secid.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} seriesRef - 'market.code' secid, e.g. '1.000001'.
|
|
56
|
+
* @param {{ from: string, to: string }} range - requested window.
|
|
57
|
+
* @param {{ klt?: number, fqt?: number, lmt?: number }} [params] - catalog params.
|
|
58
|
+
* @returns {string} request URL.
|
|
59
|
+
*/
|
|
60
|
+
export function buildUrl(seriesRef, range, params = {}) {
|
|
61
|
+
const klt = params.klt ?? 101
|
|
62
|
+
const fqt = params.fqt ?? 1
|
|
63
|
+
const lmt = params.lmt ?? DEFAULT_LIMIT
|
|
64
|
+
return (
|
|
65
|
+
'https://push2his.eastmoney.com/api/qt/stock/kline/get' +
|
|
66
|
+
`?secid=${encodeURIComponent(seriesRef)}` +
|
|
67
|
+
'&fields1=f1,f2,f3,f4,f5,f6' +
|
|
68
|
+
`&fields2=${FIELDS2.join(',')}` +
|
|
69
|
+
`&klt=${klt}&fqt=${fqt}&end=20500101&lmt=${lmt}`
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Human landing page for an index secid, plus the kline API URL.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} seriesRef - secid.
|
|
77
|
+
* @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
|
|
78
|
+
*/
|
|
79
|
+
export function sourceRef(seriesRef) {
|
|
80
|
+
const [, code = seriesRef] = seriesRef.split('.')
|
|
81
|
+
const isDomestic = seriesRef.startsWith('1.') || seriesRef.startsWith('0.')
|
|
82
|
+
return {
|
|
83
|
+
adapterId: id,
|
|
84
|
+
url: isDomestic
|
|
85
|
+
? `https://quote.eastmoney.com/zs${code}.html`
|
|
86
|
+
: 'https://quote.eastmoney.com/center/gridlist.html#global_globalindex',
|
|
87
|
+
apiUrl: buildUrl(seriesRef, { from: '2000-01-01', to: '2050-01-01' }),
|
|
88
|
+
seriesRef,
|
|
89
|
+
label,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a kline payload into ascending daily bars (`v` is the close).
|
|
95
|
+
*
|
|
96
|
+
* @param {string} body - response body.
|
|
97
|
+
* @param {string} seriesRef - secid, echoed into errors.
|
|
98
|
+
* @returns {{ name: string, points: Array<{ t: string, v: number, o?: number, h?: number, l?: number }> }} parsed series.
|
|
99
|
+
*/
|
|
100
|
+
export function parseKlines(body, seriesRef) {
|
|
101
|
+
const payload = parseJson(body, id, seriesRef)
|
|
102
|
+
if (payload === null || typeof payload !== 'object') {
|
|
103
|
+
throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'response is not a JSON object' })
|
|
104
|
+
}
|
|
105
|
+
if (payload.data == null) {
|
|
106
|
+
// An envelope without `data` is either a business rejection (`rc`) or a body
|
|
107
|
+
// that is not a kline response at all. Only the former is "unsupported".
|
|
108
|
+
const rejected = payload.rc !== undefined && payload.rc !== 0
|
|
109
|
+
throw new SourceError({
|
|
110
|
+
kind: rejected ? 'unsupported' : 'parse',
|
|
111
|
+
adapterId: id,
|
|
112
|
+
seriesRef,
|
|
113
|
+
detail: rejected
|
|
114
|
+
? `upstream rejected the secid (rc=${payload.rc})`
|
|
115
|
+
: 'response has no data.klines (not a kline payload)',
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
const rows = payload.data.klines
|
|
119
|
+
if (!Array.isArray(rows)) {
|
|
120
|
+
throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'data.klines is not an array' })
|
|
121
|
+
}
|
|
122
|
+
const points = []
|
|
123
|
+
for (const row of rows) {
|
|
124
|
+
const columns = String(row).split(',')
|
|
125
|
+
if (columns.length <= CLOSE_FIELD_INDEX) {
|
|
126
|
+
throw new SourceError({
|
|
127
|
+
kind: 'parse',
|
|
128
|
+
adapterId: id,
|
|
129
|
+
seriesRef,
|
|
130
|
+
detail: `kline row has ${columns.length} columns; expected at least ${CLOSE_FIELD_INDEX + 1} for the requested fields2`,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
const value = parseNumber(columns[CLOSE_FIELD_INDEX])
|
|
134
|
+
if (value === undefined) continue
|
|
135
|
+
// The close is the observation; the rest of the bar rides along for candles,
|
|
136
|
+
// unless the four columns contradict each other.
|
|
137
|
+
points.push(withBar(
|
|
138
|
+
{ t: columns[0], v: value },
|
|
139
|
+
{ o: parseNumber(columns[OHLC_FIELD_INDEX.open]), h: parseNumber(columns[OHLC_FIELD_INDEX.high]), l: parseNumber(columns[OHLC_FIELD_INDEX.low]) },
|
|
140
|
+
))
|
|
141
|
+
}
|
|
142
|
+
if (points.length === 0) {
|
|
143
|
+
throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'no usable kline rows in the response' })
|
|
144
|
+
}
|
|
145
|
+
points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
|
|
146
|
+
return { name: String(payload.data.name ?? seriesRef), points }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Fetch and normalize one index series.
|
|
151
|
+
*
|
|
152
|
+
* @param {{ seriesRef: string, range: { from: string, to: string }, params?: object }} req - request.
|
|
153
|
+
* @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
|
|
154
|
+
* @returns {Promise<object>} normalized 'RawSeries'.
|
|
155
|
+
*/
|
|
156
|
+
export async function fetchSeries(req, deps) {
|
|
157
|
+
const { seriesRef, range, params } = req
|
|
158
|
+
const url = buildUrl(seriesRef, range, params ?? {})
|
|
159
|
+
const { status, body } = await getText({
|
|
160
|
+
fetch: deps.fetch,
|
|
161
|
+
url,
|
|
162
|
+
adapterId: id,
|
|
163
|
+
seriesRef,
|
|
164
|
+
signal: deps.signal,
|
|
165
|
+
})
|
|
166
|
+
const { name, points } = parseKlines(body, seriesRef)
|
|
167
|
+
return {
|
|
168
|
+
adapterId: id,
|
|
169
|
+
seriesRef,
|
|
170
|
+
points,
|
|
171
|
+
meta: { name, unit: '点', freq: 'daily' },
|
|
172
|
+
fetchedAt: deps.clock.now().toISOString(),
|
|
173
|
+
sourceRef: sourceRef(seriesRef),
|
|
174
|
+
raw: { url, httpStatus: status, bars: points.length },
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Mean gap between bars, used by tests to prove daily cadence without pulling
|
|
180
|
+
* in a trading calendar (docs/03 §2 rule 12).
|
|
181
|
+
*
|
|
182
|
+
* @param {Array<{ t: string, v: number }>} points - ascending points.
|
|
183
|
+
* @returns {number} mean gap in days.
|
|
184
|
+
*/
|
|
185
|
+
export function meanGapDays(points) {
|
|
186
|
+
if (points.length < 2) return 0
|
|
187
|
+
let total = 0
|
|
188
|
+
for (let i = 1; i < points.length; i += 1) total += daysBetween(points[i - 1].t, points[i].t)
|
|
189
|
+
return total / (points.length - 1)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The earliest date a 'lmt'-bar request can reach, for catalog documentation.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} today - reference date.
|
|
196
|
+
* @param {number} [bars] - bar count.
|
|
197
|
+
* @returns {string} approximate earliest date.
|
|
198
|
+
*/
|
|
199
|
+
export function approximateHorizon(today, bars = DEFAULT_LIMIT) {
|
|
200
|
+
return addMonths(today, -Math.ceil((bars / 250) * 12))
|
|
201
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECB Data Portal (SDW) adapter (docs/04 §6).
|
|
3
|
+
*
|
|
4
|
+
* The CSV export of a dataflow is a long-format table with a wide header; the
|
|
5
|
+
* two columns that matter are 'TIME_PERIOD' and 'OBS_VALUE'. 'lastNObservations'
|
|
6
|
+
* must always be sent, or the response is the whole history.
|
|
7
|
+
*
|
|
8
|
+
* @module sources/ecb
|
|
9
|
+
*/
|
|
10
|
+
import { SourceError } from '../core/types.js'
|
|
11
|
+
import { parseNumber } from '../core/stats/series.js'
|
|
12
|
+
import { getText } from './http.js'
|
|
13
|
+
|
|
14
|
+
/** @type {string} */
|
|
15
|
+
export const id = 'ecb'
|
|
16
|
+
|
|
17
|
+
/** @type {string} */
|
|
18
|
+
export const label = 'ECB'
|
|
19
|
+
|
|
20
|
+
/** @type {Array<{ kinds: string[], frequencies: string[] }>} */
|
|
21
|
+
export const capabilities = [{ kinds: ['rates', 'fx'], frequencies: ['daily'] }]
|
|
22
|
+
|
|
23
|
+
/** Default observation count; ~6 years of daily data. */
|
|
24
|
+
const DEFAULT_OBSERVATIONS = 1600
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build a CSV export URL for one dataflow key.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} seriesRef - dataflow key, e.g. 'EXR/D.USD.EUR.SP00.A'.
|
|
30
|
+
* @param {{ lastNObservations?: number }} [params] - catalog params.
|
|
31
|
+
* @returns {string} request URL.
|
|
32
|
+
*/
|
|
33
|
+
export function buildUrl(seriesRef, { lastNObservations = DEFAULT_OBSERVATIONS } = {}) {
|
|
34
|
+
return (
|
|
35
|
+
`https://data-api.ecb.europa.eu/service/data/${seriesRef}` +
|
|
36
|
+
`?format=csvdata&lastNObservations=${lastNObservations}`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Dataset landing page plus the CSV API URL.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} seriesRef - dataflow key.
|
|
44
|
+
* @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
|
|
45
|
+
*/
|
|
46
|
+
export function sourceRef(seriesRef) {
|
|
47
|
+
const flow = seriesRef.split('/')[0]
|
|
48
|
+
return {
|
|
49
|
+
adapterId: id,
|
|
50
|
+
url: `https://data.ecb.europa.eu/data/datasets/${flow}`,
|
|
51
|
+
apiUrl: buildUrl(seriesRef),
|
|
52
|
+
seriesRef,
|
|
53
|
+
label,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse an ECB csvdata export.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} body - CSV text.
|
|
61
|
+
* @param {string} seriesRef - dataflow key (for errors).
|
|
62
|
+
* @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
|
|
63
|
+
*/
|
|
64
|
+
export function parseCsvData(body, seriesRef) {
|
|
65
|
+
const lines = body.trim().split(/\r?\n/).filter((line) => line.trim() !== '')
|
|
66
|
+
if (lines.length === 0) {
|
|
67
|
+
throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the CSV body is empty' })
|
|
68
|
+
}
|
|
69
|
+
const header = splitCsvLine(lines[0])
|
|
70
|
+
const timeIndex = header.indexOf('TIME_PERIOD')
|
|
71
|
+
const valueIndex = header.indexOf('OBS_VALUE')
|
|
72
|
+
if (timeIndex === -1 || valueIndex === -1) {
|
|
73
|
+
throw new SourceError({
|
|
74
|
+
kind: 'parse',
|
|
75
|
+
adapterId: id,
|
|
76
|
+
seriesRef,
|
|
77
|
+
detail: `CSV header lacks TIME_PERIOD/OBS_VALUE (got ${header.slice(0, 8).join(',')}…)`,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
const points = []
|
|
81
|
+
for (const line of lines.slice(1)) {
|
|
82
|
+
const columns = splitCsvLine(line)
|
|
83
|
+
const period = columns[timeIndex]
|
|
84
|
+
const value = parseNumber(columns[valueIndex])
|
|
85
|
+
if (typeof period !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(period)) continue
|
|
86
|
+
if (value === undefined) continue // empty OBS_VALUE is a missing observation
|
|
87
|
+
points.push({ t: period, v: value })
|
|
88
|
+
}
|
|
89
|
+
if (points.length === 0) {
|
|
90
|
+
throw new SourceError({
|
|
91
|
+
kind: 'empty',
|
|
92
|
+
adapterId: id,
|
|
93
|
+
seriesRef,
|
|
94
|
+
detail: 'no observations with both a date and a value',
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
|
|
98
|
+
const title = header[header.length - 1]
|
|
99
|
+
return { name: title && title.length > 3 ? title : seriesRef, points }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Split one CSV line, honouring double-quoted fields (the ECB title column
|
|
104
|
+
* contains commas).
|
|
105
|
+
*
|
|
106
|
+
* @param {string} line - CSV line.
|
|
107
|
+
* @returns {string[]} fields.
|
|
108
|
+
*/
|
|
109
|
+
export function splitCsvLine(line) {
|
|
110
|
+
const fields = []
|
|
111
|
+
let current = ''
|
|
112
|
+
let quoted = false
|
|
113
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
114
|
+
const char = line[i]
|
|
115
|
+
if (quoted) {
|
|
116
|
+
if (char === '"') {
|
|
117
|
+
if (line[i + 1] === '"') {
|
|
118
|
+
current += '"'
|
|
119
|
+
i += 1
|
|
120
|
+
} else quoted = false
|
|
121
|
+
} else current += char
|
|
122
|
+
} else if (char === '"') {
|
|
123
|
+
quoted = true
|
|
124
|
+
} else if (char === ',') {
|
|
125
|
+
fields.push(current)
|
|
126
|
+
current = ''
|
|
127
|
+
} else current += char
|
|
128
|
+
}
|
|
129
|
+
fields.push(current)
|
|
130
|
+
return fields
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fetch and normalize one ECB series.
|
|
135
|
+
*
|
|
136
|
+
* @param {{ seriesRef: string, params?: { lastNObservations?: number } }} req - request.
|
|
137
|
+
* @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
|
|
138
|
+
* @returns {Promise<object>} normalized 'RawSeries'.
|
|
139
|
+
*/
|
|
140
|
+
export async function fetchSeries(req, deps) {
|
|
141
|
+
const { seriesRef, params } = req
|
|
142
|
+
const url = buildUrl(seriesRef, params ?? {})
|
|
143
|
+
const { status, body } = await getText({
|
|
144
|
+
fetch: deps.fetch,
|
|
145
|
+
url,
|
|
146
|
+
adapterId: id,
|
|
147
|
+
seriesRef,
|
|
148
|
+
signal: deps.signal,
|
|
149
|
+
allowHttpError: true,
|
|
150
|
+
})
|
|
151
|
+
if (status >= 400 && status < 500) {
|
|
152
|
+
throw new SourceError({
|
|
153
|
+
kind: 'unsupported',
|
|
154
|
+
adapterId: id,
|
|
155
|
+
seriesRef,
|
|
156
|
+
detail: `ECB data portal returned HTTP ${status} for ${seriesRef}; the dataflow key is not valid`,
|
|
157
|
+
httpStatus: status,
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
if (status >= 500) {
|
|
161
|
+
throw new SourceError({
|
|
162
|
+
kind: 'http',
|
|
163
|
+
adapterId: id,
|
|
164
|
+
seriesRef,
|
|
165
|
+
detail: `ECB data portal is unavailable (HTTP ${status})`,
|
|
166
|
+
httpStatus: status,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
const { name, points } = parseCsvData(body, seriesRef)
|
|
170
|
+
return {
|
|
171
|
+
adapterId: id,
|
|
172
|
+
seriesRef,
|
|
173
|
+
points,
|
|
174
|
+
meta: { name, unit: undefined, freq: 'daily' },
|
|
175
|
+
fetchedAt: deps.clock.now().toISOString(),
|
|
176
|
+
sourceRef: sourceRef(seriesRef),
|
|
177
|
+
raw: { url, httpStatus: status, rows: points.length },
|
|
178
|
+
}
|
|
179
|
+
}
|