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,207 @@
1
+ /**
2
+ * FRED CSV adapter — the backbone source (docs/04 §1).
3
+ *
4
+ * 'fredgraph.csv' needs no API key and returns a two-column CSV whose first
5
+ * column is always 'observation_date'; a missing observation is a literal '.'
6
+ * and must be skipped, never read as zero.
7
+ *
8
+ * @module sources/fred
9
+ */
10
+ import { SourceError } from '../core/types.js'
11
+ import { addDays, addMonths, daysBetween } from '../core/time/range.js'
12
+ import { parseNumber } from '../core/stats/series.js'
13
+ import { getText } from './http.js'
14
+
15
+ /** @type {string} */
16
+ export const id = 'fred'
17
+
18
+ /** @type {string} */
19
+ export const label = 'FRED'
20
+
21
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
22
+ export const capabilities = [
23
+ { kinds: ['macro', 'rates', 'equity', 'commodity', 'labor'], frequencies: ['daily', 'weekly', 'monthly', 'quarterly', 'annual'] },
24
+ ]
25
+
26
+ /** How far before 'from' the adapter requests history, so annual comparisons work. */
27
+ const LOOKBACK_MONTHS = 13
28
+
29
+ /** Slugs that mean "the whole series", independent of frequency. */
30
+ const MAX_LOOKBACK_MONTHS = 12 * 60
31
+
32
+ /**
33
+ * Build the CSV URL for one series.
34
+ *
35
+ * 'cosd' is mandatory in practice: without it 'GDPC1' returns its full history
36
+ * since 1947 (docs/04 §1).
37
+ *
38
+ * @param {string} seriesRef - FRED series id.
39
+ * @param {{ from: string, to: string }} range - requested window.
40
+ * @param {{ preset?: string }} [options] - range preset, when known.
41
+ * @returns {string} request URL.
42
+ */
43
+ export function buildUrl(seriesRef, range, { preset } = {}) {
44
+ const cosd = preset === 'MAX' ? addMonths(range.from, -MAX_LOOKBACK_MONTHS) : addMonths(range.from, -LOOKBACK_MONTHS)
45
+ return `https://fred.stlouisfed.org/graph/fredgraph.csv?id=${encodeURIComponent(seriesRef)}&cosd=${cosd}&coed=${range.to}`
46
+ }
47
+
48
+ /**
49
+ * Human-readable landing page plus the machine-readable CSV URL (docs/03 §1.1).
50
+ *
51
+ * @param {string} seriesRef - FRED series id.
52
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
53
+ */
54
+ export function sourceRef(seriesRef) {
55
+ return {
56
+ adapterId: id,
57
+ url: `https://fred.stlouisfed.org/series/${encodeURIComponent(seriesRef)}`,
58
+ apiUrl: `https://fred.stlouisfed.org/graph/fredgraph.csv?id=${encodeURIComponent(seriesRef)}`,
59
+ seriesRef,
60
+ label,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Parse a FRED CSV body.
66
+ *
67
+ * @param {string} body - CSV text.
68
+ * @param {string} seriesRef - expected series id (the header's second column).
69
+ * @param {{ adapterId?: string }} [options] - owning adapter id for errors.
70
+ * @returns {Array<{ t: string, v: number }>} ascending points.
71
+ */
72
+ export function parseCsv(body, seriesRef, { adapterId = id } = {}) {
73
+ const lines = body.trim().split(/\r?\n/)
74
+ if (lines.length === 0 || lines[0].trim() === '') {
75
+ throw new SourceError({ kind: 'empty', adapterId, seriesRef, detail: 'CSV body has no header row' })
76
+ }
77
+ const header = lines[0].split(',')
78
+ if (header.length < 2) {
79
+ throw new SourceError({ kind: 'parse', adapterId, seriesRef, detail: `expected two CSV columns, got ${JSON.stringify(lines[0])}` })
80
+ }
81
+ if (header[0].trim() !== 'observation_date') {
82
+ throw new SourceError({
83
+ kind: 'parse',
84
+ adapterId,
85
+ seriesRef,
86
+ detail: `unexpected first column ${JSON.stringify(header[0])}; expected observation_date`,
87
+ })
88
+ }
89
+ const points = []
90
+ for (const line of lines.slice(1)) {
91
+ if (line.trim() === '') continue
92
+ const [date, raw] = line.split(',')
93
+ const value = parseNumber(raw)
94
+ if (value === undefined) continue // '.' and friends: missing, not zero
95
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
96
+ points.push({ t: date, v: value })
97
+ }
98
+ if (points.length === 0) {
99
+ throw new SourceError({
100
+ kind: 'empty',
101
+ adapterId,
102
+ seriesRef,
103
+ detail: 'the CSV parsed but contained no usable observations',
104
+ })
105
+ }
106
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
107
+ return points
108
+ }
109
+
110
+ /**
111
+ * Fetch and normalize one FRED series.
112
+ *
113
+ * @param {{ seriesRef: string, range: { from: string, to: string }, preset?: string }} req - request.
114
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
115
+ * @returns {Promise<object>} normalized 'RawSeries'.
116
+ */
117
+ export async function fetchSeries(req, deps) {
118
+ const { seriesRef, range, preset } = req
119
+ const url = buildUrl(seriesRef, range, { preset })
120
+ const { status, body } = await getText({
121
+ fetch: deps.fetch,
122
+ url,
123
+ adapterId: id,
124
+ seriesRef,
125
+ signal: deps.signal,
126
+ allowHttpError: true,
127
+ })
128
+
129
+ if (status >= 400 && status < 500) {
130
+ // FRED answers an unknown series id with a 404 HTML page, not error JSON:
131
+ // that is a wrong id (never retryable), not a transient failure.
132
+ throw new SourceError({
133
+ kind: 'unsupported',
134
+ adapterId: id,
135
+ seriesRef,
136
+ detail: `FRED returned HTTP ${status} for ${seriesRef}; the series id does not exist`,
137
+ httpStatus: status,
138
+ })
139
+ }
140
+ if (status >= 500) {
141
+ throw new SourceError({
142
+ kind: 'http',
143
+ adapterId: id,
144
+ seriesRef,
145
+ detail: `FRED is unavailable (HTTP ${status})`,
146
+ httpStatus: status,
147
+ })
148
+ }
149
+ if (body.trimStart().startsWith('<')) {
150
+ throw new SourceError({
151
+ kind: 'parse',
152
+ adapterId: id,
153
+ seriesRef,
154
+ detail: 'FRED returned an HTML page instead of CSV',
155
+ httpStatus: status,
156
+ })
157
+ }
158
+
159
+ const points = parseCsv(body, seriesRef)
160
+ return {
161
+ adapterId: id,
162
+ seriesRef,
163
+ points,
164
+ meta: {
165
+ // `fredgraph.csv` labels the column with the series id, not a human
166
+ // title; the catalog carries the human label, so the source name stays
167
+ // machine-checkable here.
168
+ name: seriesRef,
169
+ unit: undefined,
170
+ freq: inferFrequency(points),
171
+ },
172
+ fetchedAt: deps.clock.now().toISOString(),
173
+ sourceRef: sourceRef(seriesRef),
174
+ raw: { url, httpStatus: status, bytes: body.length },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Infer the observation cadence from the median gap — used for display only,
180
+ * the catalog's 'freq' is authoritative for TTL and staleness.
181
+ *
182
+ * @param {Array<{ t: string, v: number }>} points - ascending points.
183
+ * @returns {string} frequency id.
184
+ */
185
+ export function inferFrequency(points) {
186
+ if (points.length < 3) return 'monthly'
187
+ const gaps = []
188
+ for (let i = 1; i < points.length; i += 1) gaps.push(daysBetween(points[i - 1].t, points[i].t))
189
+ gaps.sort((a, b) => a - b)
190
+ const median = gaps[Math.floor(gaps.length / 2)]
191
+ if (median <= 3) return 'daily'
192
+ if (median <= 14) return 'weekly'
193
+ if (median <= 45) return 'monthly'
194
+ if (median <= 120) return 'quarterly'
195
+ return 'annual'
196
+ }
197
+
198
+ /**
199
+ * The window actually covered by a request (kept separate so tests can assert
200
+ * the 'cosd' expansion without a network round trip).
201
+ *
202
+ * @param {{ from: string, to: string }} range - requested window.
203
+ * @returns {{ from: string, to: string }} expanded window.
204
+ */
205
+ export function requestedWindow(range) {
206
+ return { from: addMonths(range.from, -LOOKBACK_MONTHS), to: addDays(range.to, 0) }
207
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Shared HTTP plumbing for adapters — the only place a source touches the
3
+ * network (docs/03 §3.1). Adapters stay thin and pure over the text they get
4
+ * back, which is what keeps them fixture-replayable.
5
+ *
6
+ * @module sources/http
7
+ */
8
+ import { SourceError } from '../core/types.js'
9
+
10
+ /** Per-request timeout (docs/04 §8). */
11
+ export const REQUEST_TIMEOUT_MS = 15_000
12
+
13
+ /** Browser-ish user agent; several of these sources reject the default. */
14
+ export const USER_AGENT = 'Mozilla/5.0 (compatible; dsh-plugin-show-me-data)'
15
+
16
+ /**
17
+ * Compose the caller's cancellation signal with this request's own timeout.
18
+ *
19
+ * @param {AbortSignal|undefined} signal - caller signal.
20
+ * @param {number} timeoutMs - timeout budget.
21
+ * @returns {{ signal: AbortSignal, dispose: () => void }} combined signal and cleanup.
22
+ */
23
+ function withTimeout(signal, timeoutMs) {
24
+ const timeout = new AbortController()
25
+ const timer = setTimeout(() => timeout.abort(new Error('timeout')), timeoutMs)
26
+ if (signal === undefined) return { signal: timeout.signal, dispose: () => clearTimeout(timer) }
27
+ const signals = [signal, timeout.signal]
28
+ const combined = typeof AbortSignal.any === 'function' ? AbortSignal.any(signals) : timeout.signal
29
+ return { signal: combined, dispose: () => clearTimeout(timer) }
30
+ }
31
+
32
+ /**
33
+ * Fetch one URL and return its body as text, classifying every failure into a
34
+ * 'SourceError' (docs/03 §1.7). Non-2xx responses are returned to the caller for
35
+ * 'parse'-level judgement only when 'allowHttpError' is set; otherwise they are
36
+ * 'http' errors.
37
+ *
38
+ * @param {object} options - request options.
39
+ * @param {Function} options.fetch - the 'fetch' implementation from 'deps.fetch'.
40
+ * @param {string} options.url - absolute URL.
41
+ * @param {string} options.adapterId - owning adapter.
42
+ * @param {string} options.seriesRef - requested series.
43
+ * @param {AbortSignal} [options.signal] - caller cancellation.
44
+ * @param {number} [options.timeoutMs] - timeout budget.
45
+ * @param {boolean} [options.allowHttpError] - return non-2xx bodies instead of throwing.
46
+ * @param {Record<string, string>} [options.headers] - extra headers.
47
+ * @returns {Promise<{ status: number, body: string, url: string }>} response body.
48
+ */
49
+ export async function getText({
50
+ fetch,
51
+ url,
52
+ adapterId,
53
+ seriesRef,
54
+ signal,
55
+ timeoutMs = REQUEST_TIMEOUT_MS,
56
+ allowHttpError = false,
57
+ headers = {},
58
+ }) {
59
+ if (typeof fetch !== 'function') {
60
+ throw new SourceError({
61
+ kind: 'network',
62
+ adapterId,
63
+ seriesRef,
64
+ detail: 'no fetch implementation was injected',
65
+ })
66
+ }
67
+ const { signal: combined, dispose } = withTimeout(signal, timeoutMs)
68
+ let response
69
+ try {
70
+ response = await fetch(url, { signal: combined, headers: { 'user-agent': USER_AGENT, ...headers } })
71
+ } catch (error) {
72
+ const aborted = error?.name === 'AbortError' || error?.name === 'TimeoutError'
73
+ throw new SourceError({
74
+ kind: 'network',
75
+ adapterId,
76
+ seriesRef,
77
+ detail: aborted ? `request aborted after ${timeoutMs}ms` : `${error?.name ?? 'Error'}: ${error?.message ?? error}`,
78
+ })
79
+ } finally {
80
+ dispose()
81
+ }
82
+
83
+ if (!response.ok && !allowHttpError) {
84
+ throw new SourceError({
85
+ kind: 'http',
86
+ adapterId,
87
+ seriesRef,
88
+ detail: `upstream responded ${response.status} ${response.statusText ?? ''}`.trim(),
89
+ httpStatus: response.status,
90
+ })
91
+ }
92
+
93
+ let body
94
+ try {
95
+ body = await response.text()
96
+ } catch (error) {
97
+ throw new SourceError({
98
+ kind: 'network',
99
+ adapterId,
100
+ seriesRef,
101
+ detail: `reading the response body failed: ${error?.message ?? error}`,
102
+ })
103
+ }
104
+
105
+ if (body.trim() === '') {
106
+ throw new SourceError({
107
+ kind: 'empty',
108
+ adapterId,
109
+ seriesRef,
110
+ detail: `upstream returned an empty body (HTTP ${response.status})`,
111
+ httpStatus: response.status,
112
+ })
113
+ }
114
+ return { status: response.status, body, url }
115
+ }
116
+
117
+ /**
118
+ * Parse JSON with a classified error instead of a raw 'SyntaxError'.
119
+ *
120
+ * @param {string} body - response body.
121
+ * @param {string} adapterId - owning adapter.
122
+ * @param {string} seriesRef - requested series.
123
+ * @returns {any} parsed value.
124
+ */
125
+ export function parseJson(body, adapterId, seriesRef) {
126
+ try {
127
+ return JSON.parse(body)
128
+ } catch (error) {
129
+ throw new SourceError({
130
+ kind: 'parse',
131
+ adapterId,
132
+ seriesRef,
133
+ detail: `response is not JSON (${String(body.slice(0, 80)).replace(/\s+/g, ' ')}…)`,
134
+ })
135
+ }
136
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one place an OHLC bar is accepted or rejected.
3
+ *
4
+ * Sources hand over four columns and are not guaranteed to agree with each
5
+ * other: the live Sina US payload contains a session whose low sits *above* its
6
+ * open. Forwarding that would draw a candle that cannot exist, and a panel that
7
+ * says "the low was higher than the open" has published a false number. The bar
8
+ * is therefore dropped while the close — a real observation — is kept, so the
9
+ * series loses detail and never loses a reading.
10
+ *
11
+ * @module sources/ohlc
12
+ */
13
+
14
+ /**
15
+ * Whether four numbers form a coherent bar: high bounds every other price, low is
16
+ * bounded by every other price.
17
+ *
18
+ * @param {{ o: unknown, h: unknown, l: unknown, c: unknown }} bar - candidate bar.
19
+ * @returns {boolean} whether the bar is coherent and finite.
20
+ */
21
+ export function coherentBar({ o, h, l, c }) {
22
+ if (!Number.isFinite(o) || !Number.isFinite(h) || !Number.isFinite(l) || !Number.isFinite(c)) return false
23
+ return h >= Math.max(o, c) && l <= Math.min(o, c)
24
+ }
25
+
26
+ /**
27
+ * Attach the bar to a point, or leave the point as a plain observation.
28
+ *
29
+ * @param {{ t: string, v: number }} point - point carrying the close as 'v'.
30
+ * @param {{ o: unknown, h: unknown, l: unknown }} bar - candidate bar columns.
31
+ * @returns {{ t: string, v: number, o?: number, h?: number, l?: number }} the point.
32
+ */
33
+ export function withBar(point, bar) {
34
+ if (!coherentBar({ o: bar.o, h: bar.h, l: bar.l, c: point.v })) return point
35
+ return { ...point, o: bar.o, h: bar.h, l: bar.l }
36
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Quote cascade — try each quote source in order, remember which one works.
3
+ *
4
+ * The quote endpoints used here are unofficial and block from time to time
5
+ * (observed in one session: 东方财富 'push2his' resetting connections, then
6
+ * 腾讯 answering 501). A single hard binding would therefore take a whole group
7
+ * of equity cards down whenever one operator tightens its WAF, so this adapter
8
+ * keeps **the catalog binding stable** and moves the choice of upstream into
9
+ * runtime: each request tries the configured chain and returns the first
10
+ * success; the winner is remembered for later requests in the same process.
11
+ *
12
+ * The chain is data (one entry per upstream), and each entry is an ordinary
13
+ * adapter from the registry — so this file adds resilience without duplicating
14
+ * any parsing logic.
15
+ *
16
+ * @module sources/quote-cascade
17
+ */
18
+ import { SourceError } from '../core/types.js'
19
+
20
+ /** @type {string} */
21
+ export const id = 'quote'
22
+
23
+ /** @type {string} */
24
+ export const label = '行情(多源)'
25
+
26
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
27
+ export const capabilities = [{ kinds: ['equity', 'bond'], frequencies: ['daily'] }]
28
+
29
+ /**
30
+ * The upstream chain, in preference order.
31
+ *
32
+ * 'bindings' maps a cascade symbol to each upstream's own series reference.
33
+ *
34
+ * @type {Array<{ adapterId: string, bindings: Record<string, { seriesRef: string, params?: object }> }>}
35
+ */
36
+ export const CHAIN = [
37
+ // Ordered by what actually answers from this network, most reliable first;
38
+ // the rest stay in the chain so a future block on one operator degrades
39
+ // instead of taking the cards down.
40
+ { adapterId: 'sina-cn', bindings: {} },
41
+ { adapterId: 'tencent', bindings: {} },
42
+ { adapterId: 'eastmoney-quote', bindings: {} },
43
+ ]
44
+
45
+ /** Symbols the cascade serves, with their per-upstream references. */
46
+ export const SYMBOLS = {
47
+ sse: { zh: '上证指数', 'sina-cn': 'sh000001', tencent: 'sh000001', 'eastmoney-quote': '1.000001' },
48
+ szse: { zh: '深证成指', 'sina-cn': 'sz399001', tencent: 'sz399001', 'eastmoney-quote': '0.399001' },
49
+ csi300: { zh: '沪深 300', 'sina-cn': 'sh000300', tencent: 'sh000300', 'eastmoney-quote': '1.000300' },
50
+ chinext: { zh: '创业板指', 'sina-cn': 'sz399006', tencent: 'sz399006', 'eastmoney-quote': '0.399006' },
51
+ govtbond: { zh: '上证国债指数', 'sina-cn': 'sh000012', tencent: 'sh000012', 'eastmoney-quote': '1.000012' },
52
+ corpbond: { zh: '上证企债指数', 'sina-cn': 'sh000013', tencent: 'sh000013', 'eastmoney-quote': '1.000013' },
53
+ dividend: { zh: '上证红利指数', 'sina-cn': 'sh000015', tencent: 'sh000015', 'eastmoney-quote': '1.000015' },
54
+ }
55
+
56
+ /**
57
+ * The cascade can re-sample: whichever upstream answers, it takes a bar size.
58
+ *
59
+ * The panel sends the size as a neutral name because members disagree on the
60
+ * parameter ('klt' vs 'scale'), and only this adapter knows which member will be
61
+ * tried.
62
+ */
63
+ export const barSizes = true
64
+
65
+ /** How each member spells a bar size. */
66
+ const SIZE_PARAMS = {
67
+ 'eastmoney-quote': { day: 101, week: 102, month: 103 },
68
+ 'sina-cn': { day: 240, week: 1200, month: 7200 },
69
+ }
70
+
71
+ /**
72
+ * Translate the neutral size into the member's own parameter.
73
+ *
74
+ * @param {string} adapterId - upstream adapter id.
75
+ * @param {{ size?: string }} [params] - request params.
76
+ * @returns {object|undefined} member params.
77
+ */
78
+ function paramsFor(adapterId, params) {
79
+ if (params === undefined || params.size === undefined) return params
80
+ // `size` is the cascade's own vocabulary: it must not reach a member, which
81
+ // would then put it in that member's cache key and URL.
82
+ const { size, ...rest } = params
83
+ const mapped = SIZE_PARAMS[adapterId]?.[size]
84
+ if (mapped === undefined) return rest
85
+ return { ...rest, ...(adapterId === 'eastmoney-quote' ? { klt: mapped } : { scale: mapped }) }
86
+ }
87
+
88
+ /** The last upstream that worked, remembered per symbol for this process. */
89
+ const preferred = new Map()
90
+
91
+ /**
92
+ * Build the ordered upstream attempts for one symbol.
93
+ *
94
+ * @param {string} seriesRef - cascade symbol (e.g. 'sse').
95
+ * @returns {Array<{ adapterId: string, seriesRef: string, params?: object }>} attempts.
96
+ */
97
+ export function attemptsFor(seriesRef) {
98
+ const symbol = SYMBOLS[seriesRef]
99
+ if (symbol === undefined) {
100
+ throw new SourceError({
101
+ kind: 'unsupported',
102
+ adapterId: id,
103
+ seriesRef,
104
+ detail: `unknown quote symbol ${JSON.stringify(seriesRef)}; known: ${Object.keys(SYMBOLS).join(', ')}`,
105
+ })
106
+ }
107
+ const attempted = CHAIN.map((entry) => ({
108
+ adapterId: entry.adapterId,
109
+ seriesRef: symbol[entry.adapterId],
110
+ })).filter((entry) => typeof entry.seriesRef === 'string')
111
+ // Start with whichever upstream answered last: a blocked operator rarely
112
+ // recovers within a session, and this avoids paying its timeout every refresh.
113
+ const winner = preferred.get(seriesRef)
114
+ if (winner === undefined) return attempted
115
+ return [...attempted].sort((a, b) => (a.adapterId === winner ? -1 : b.adapterId === winner ? 1 : 0))
116
+ }
117
+
118
+ /**
119
+ * Source reference for the upstream that is currently preferred.
120
+ *
121
+ * @param {string} seriesRef - cascade symbol.
122
+ * @param {object} [params] - catalog params.
123
+ * @param {(id: string, ref: string) => object|undefined} [resolve] - registry lookup.
124
+ * @returns {{ adapterId: string, url: string, seriesRef: string, label: string, apiUrl?: string }} source reference.
125
+ */
126
+ export function sourceRef(seriesRef, params, resolve) {
127
+ const attempts = attemptsFor(seriesRef)
128
+ const first = attempts[0]
129
+ const upstream = resolve?.(first.adapterId, first.seriesRef)
130
+ if (upstream !== undefined) return upstream
131
+ return { adapterId: id, url: 'https://quote.eastmoney.com/', seriesRef, label }
132
+ }
133
+
134
+ /**
135
+ * Fetch one symbol, falling through the chain.
136
+ *
137
+ * @param {{ seriesRef: string, params?: object, range?: object, preset?: string }} req - request.
138
+ * @param {{ fetch: Function, clock: object, signal?: AbortSignal, fetchViaAdapter?: Function, resolveSourceRef?: Function }} deps - injected dependencies.
139
+ * @returns {Promise<object>} normalized 'RawSeries' from the first upstream that answers.
140
+ */
141
+ export async function fetchSeries(req, deps) {
142
+ const attempts = attemptsFor(req.seriesRef)
143
+ const failures = []
144
+ for (const attempt of attempts) {
145
+ try {
146
+ const series = await deps.fetchViaAdapter(attempt.adapterId, { ...req, seriesRef: attempt.seriesRef, params: paramsFor(attempt.adapterId, req.params) }, deps)
147
+ preferred.set(req.seriesRef, attempt.adapterId)
148
+ return {
149
+ ...series,
150
+ // The series identity stays the cascade symbol so the cache key and the
151
+ // catalog binding do not move when the upstream changes.
152
+ seriesRef: req.seriesRef,
153
+ raw: { ...(series.raw ?? {}), via: attempt.adapterId, upstreamSeriesRef: attempt.seriesRef },
154
+ }
155
+ } catch (error) {
156
+ failures.push({ adapterId: attempt.adapterId, seriesRef: attempt.seriesRef, kind: error?.kind ?? 'unknown', detail: error?.detail ?? String(error?.message ?? error) })
157
+ }
158
+ }
159
+ throw new SourceError({
160
+ kind: 'http',
161
+ adapterId: id,
162
+ seriesRef: req.seriesRef,
163
+ detail: `every upstream failed: ${failures.map((failure) => `${failure.adapterId}=${failure.kind}`).join(', ')}`,
164
+ ...(failures[0]?.detail === undefined ? {} : { httpStatus: undefined }),
165
+ })
166
+ }
167
+
168
+ /**
169
+ * Forget which upstream worked (used by tests and by a manual 'data_refresh').
170
+ *
171
+ * @param {string} [seriesRef] - symbol, or all when omitted.
172
+ * @returns {void}
173
+ */
174
+ export function resetPreference(seriesRef) {
175
+ if (seriesRef === undefined) preferred.clear()
176
+ else preferred.delete(seriesRef)
177
+ }