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,153 @@
1
+ /**
2
+ * Adapter registry — the single extension point for data sources (docs/02 §5.2).
3
+ *
4
+ * Adding a source is one file plus one row in {@link ADAPTERS}; the contract
5
+ * suite walks this list, so a new adapter is covered automatically.
6
+ *
7
+ * @module sources/registry
8
+ */
9
+ import * as ecb from './ecb.js'
10
+ import * as eastmoneyMacro from './eastmoney-macro.js'
11
+ import * as eastmoneyQuote from './eastmoney-quote.js'
12
+ import * as fred from './fred.js'
13
+ import * as quoteCascade from './quote-cascade.js'
14
+ import * as sinaCn from './sina-cn.js'
15
+ import * as sinaUs from './sina-us.js'
16
+ import * as tencent from './tencent.js'
17
+ import * as usTreasury from './us-treasury.js'
18
+ import * as usTreasuryRates from './us-treasury-rates.js'
19
+ import * as worldbank from './worldbank.js'
20
+ import { SourceError } from '../core/types.js'
21
+
22
+ /** Every registered adapter, in documentation order. */
23
+ export const ADAPTERS = [fred, eastmoneyQuote, eastmoneyMacro, usTreasury, usTreasuryRates, worldbank, ecb, tencent, sinaCn, sinaUs, quoteCascade]
24
+
25
+ /** @type {Map<string, any>} */
26
+ const BY_ID = new Map(ADAPTERS.map((adapter) => [adapter.id, adapter]))
27
+
28
+ /** Adapter ids that must exist for the plugin to be useful (docs/07 T3.1). */
29
+ export const REQUIRED_ADAPTER_IDS = [
30
+ 'fred',
31
+ 'eastmoney-quote',
32
+ 'eastmoney-macro',
33
+ 'us-treasury',
34
+ 'us-treasury-rates',
35
+ 'worldbank',
36
+ 'ecb',
37
+ 'tencent',
38
+ 'sina-cn',
39
+ 'sina-us',
40
+ 'quote',
41
+ ]
42
+
43
+ /**
44
+ * Look one adapter up by id.
45
+ *
46
+ * @param {string} id - adapter id.
47
+ * @returns {any|undefined} adapter module.
48
+ */
49
+ export function getAdapter(id) {
50
+ return BY_ID.get(id)
51
+ }
52
+
53
+ /**
54
+ * All registered adapter ids.
55
+ *
56
+ * @returns {string[]} adapter ids.
57
+ */
58
+ export function adapterIds() {
59
+ return [...BY_ID.keys()]
60
+ }
61
+
62
+ /**
63
+ * Whether an adapter id is registered.
64
+ *
65
+ * @param {string} id - adapter id.
66
+ * @returns {boolean} registration flag.
67
+ */
68
+ export function hasAdapter(id) {
69
+ return BY_ID.has(id)
70
+ }
71
+
72
+ /**
73
+ * Every adapter's declared capabilities, for the settings/health surface.
74
+ *
75
+ * @returns {Array<{ id: string, label: string, capabilities: any[] }>} capability list.
76
+ */
77
+ export function listCapabilities() {
78
+ return ADAPTERS.map((adapter) => ({ id: adapter.id, label: adapter.label, capabilities: adapter.capabilities }))
79
+ }
80
+
81
+ /**
82
+ * Whether an adapter can re-sample its bars (day/week/month).
83
+ *
84
+ * Declared per adapter rather than inferred from the request: the Sina US index
85
+ * endpoint is daily-only and silently ignores a `klt` parameter, so offering a
86
+ * "weekly" button for it produced three identical charts — a control that looks
87
+ * like it works and does nothing. The panel only shows the control when this is
88
+ * true.
89
+ *
90
+ * @param {string} adapterId - adapter id.
91
+ * @returns {boolean} whether 'params.klt' changes the returned bars.
92
+ */
93
+ export function supportsBarSize(adapterId) {
94
+ return BY_ID.get(adapterId)?.barSizes === true
95
+ }
96
+
97
+ /**
98
+ * The per-source fetch budget, when an adapter declares one.
99
+ *
100
+ * The default (docs/04 §8) is sized for a fast API. A source that is legitimate
101
+ * but slow — the World Bank's indicator endpoint answers in 0.4–14s from here —
102
+ * needs its own budget, otherwise "slow" is indistinguishable from "down" and the
103
+ * card shows a false failure.
104
+ *
105
+ * @param {string} adapterId - adapter id.
106
+ * @returns {number|undefined} budget in ms, or 'undefined' for the default.
107
+ */
108
+ export function sourceTimeoutMs(adapterId) {
109
+ const declared = BY_ID.get(adapterId)?.timeoutMs
110
+ return typeof declared === 'number' && declared > 0 ? declared : undefined
111
+ }
112
+
113
+ /**
114
+ * Resolve a source reference for a catalog binding.
115
+ *
116
+ * @param {string} adapterId - adapter id.
117
+ * @param {string} seriesRef - series reference.
118
+ * @param {object} [params] - catalog params.
119
+ * @returns {{ url: string, apiUrl?: string, seriesRef: string, label: string }|undefined} source reference.
120
+ */
121
+ export function resolveSourceRef(adapterId, seriesRef, params) {
122
+ const adapter = BY_ID.get(adapterId)
123
+ if (adapter === undefined) return undefined
124
+ return adapter.sourceRef(seriesRef, params)
125
+ }
126
+
127
+ /**
128
+ * Fetch a series through its adapter.
129
+ *
130
+ * @param {string} adapterId - adapter id.
131
+ * @param {{ seriesRef: string, range: object, params?: object, preset?: string }} req - request.
132
+ * @param {{ fetch: Function, clock: object, signal?: AbortSignal }} deps - injected dependencies.
133
+ * @returns {Promise<object>} normalized 'RawSeries'.
134
+ */
135
+ export async function fetchViaAdapter(adapterId, req, deps) {
136
+ const adapter = BY_ID.get(adapterId)
137
+ if (adapter === undefined) {
138
+ const { SourceError } = await import('../core/types.js')
139
+ throw new SourceError({
140
+ kind: 'unsupported',
141
+ adapterId,
142
+ seriesRef: req.seriesRef,
143
+ detail: `no adapter registered under ${JSON.stringify(adapterId)}; known: ${adapterIds().join(', ')}`,
144
+ })
145
+ }
146
+ // Adapters that compose others (the quote cascade) receive the dispatcher and
147
+ // the sourceRef resolver; plain adapters ignore them.
148
+ return adapter.fetchSeries(req, {
149
+ ...deps,
150
+ fetchViaAdapter: (innerId, innerReq, innerDeps) => fetchViaAdapter(innerId, innerReq, innerDeps ?? deps),
151
+ resolveSourceRef,
152
+ })
153
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * 新浪 A 股指数 adapter — daily history for mainland indices and bond indices.
3
+ *
4
+ * Used as the quote source for this deployment because 东方财富's quote host
5
+ * resets connections and 腾讯's kline host answers 501 from this network. The
6
+ * endpoint is the one behind Sina's own index pages and returns a JSONP array.
7
+ *
8
+ * x([{ day: 2026-09-11, open: 3910.920, high: 3912.320, low: 3852.030, close: 3888.110 }])
9
+ *
10
+ * Traps this adapter contains:
11
+ * - the body is **JSONP**: a redirect prefix precedes the wrapper, so the
12
+ * payload must be located by its parentheses rather than by an exact prefix;
13
+ * - an unknown symbol answers x(null), which must be an 'unsupported' error
14
+ * rather than a crash on `.length`;
15
+ * - every field is a **string**; `close` is the value, `volume` is ignored;
16
+ * - 恒生指数 is *not* served by this endpoint (verified: `x(null)`), so `hkHSI`
17
+ * is deliberately absent from {@link SYMBOLS}.
18
+ *
19
+ * @module sources/sina-cn
20
+ */
21
+ import { SourceError } from '../core/types.js'
22
+ import { withBar } from './ohlc.js'
23
+ import { parseNumber } from '../core/stats/series.js'
24
+ import { getText } from './http.js'
25
+
26
+ /** @type {string} */
27
+ export const id = 'sina-cn'
28
+
29
+ /** @type {string} */
30
+ export const label = '新浪财经·A股'
31
+
32
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
33
+ export const capabilities = [{ kinds: ['equity', 'bond'], frequencies: ['daily'] }]
34
+
35
+ /**
36
+ * The symbols this adapter serves, each verified live.
37
+ *
38
+ * @type {Record<string, { name: string, page: string }>}
39
+ */
40
+ export const SYMBOLS = {
41
+ sh000001: { name: '上证指数', page: 'https://finance.sina.com.cn/realstock/company/sh000001/nc.shtml' },
42
+ sz399001: { name: '深证成指', page: 'https://finance.sina.com.cn/realstock/company/sz399001/nc.shtml' },
43
+ sh000300: { name: '沪深 300', page: 'https://finance.sina.com.cn/realstock/company/sh000300/nc.shtml' },
44
+ sz399006: { name: '创业板指', page: 'https://finance.sina.com.cn/realstock/company/sz399006/nc.shtml' },
45
+ sh000012: { name: '上证国债指数', page: 'https://finance.sina.com.cn/realstock/company/sh000012/nc.shtml' },
46
+ sh000013: { name: '上证企债指数', page: 'https://finance.sina.com.cn/realstock/company/sh000013/nc.shtml' },
47
+ sh000015: { name: '上证红利指数', page: 'https://finance.sina.com.cn/realstock/company/sh000015/nc.shtml' },
48
+ }
49
+
50
+ /** Default bar count. */
51
+ const DEFAULT_LIMIT = 1200
52
+
53
+ /** Its endpoint takes a 'scale' in minutes, so day/week/month bars are all real. */
54
+ export const barSizes = true
55
+
56
+ /**
57
+ * Bar sizes ('scale', in minutes): 240 is one trading day, 1200 a week, 7200 a month.
58
+ *
59
+ * Sina accepts other values (5/15/30/60 minute bars); only the three the panel
60
+ * offers are listed here.
61
+ */
62
+ export const SCALE_BY_SIZE = { day: 240, week: 1200, month: 7200 }
63
+
64
+ /**
65
+ * Build the kline URL for one symbol.
66
+ *
67
+ * @param {string} seriesRef - Sina symbol, e.g. 'sh000001'.
68
+ * @param {{ count?: number, scale?: number }} [params] - catalog params.
69
+ * @returns {string} request URL.
70
+ */
71
+ export function buildUrl(seriesRef, params = {}) {
72
+ const count = params.count ?? DEFAULT_LIMIT
73
+ const scale = params.scale ?? SCALE_BY_SIZE.day
74
+ return `https://quotes.sina.cn/cn/api/jsonp_v2.php/x/CN_MarketDataService.getKLineData?symbol=${encodeURIComponent(seriesRef)}&scale=${scale}&ma=no&datalen=${count}`
75
+ }
76
+
77
+ /**
78
+ * Source reference for one symbol.
79
+ *
80
+ * @param {string} seriesRef - Sina symbol.
81
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
82
+ */
83
+ export function sourceRef(seriesRef) {
84
+ return {
85
+ adapterId: id,
86
+ url: SYMBOLS[seriesRef]?.page ?? 'https://finance.sina.com.cn/stock/',
87
+ apiUrl: buildUrl(seriesRef),
88
+ seriesRef,
89
+ label,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Strip the JSONP wrapper (tolerating a redirect prefix) and parse it.
95
+ *
96
+ * @param {string} body - response body.
97
+ * @param {string} seriesRef - symbol (for errors).
98
+ * @returns {any} parsed payload ('null' for an unknown symbol).
99
+ */
100
+ export function parseJsonp(body, seriesRef) {
101
+ const text = String(body ?? '')
102
+ const start = text.indexOf('(')
103
+ const end = text.lastIndexOf(')')
104
+ if (start === -1 || end === -1 || end <= start) {
105
+ throw new SourceError({
106
+ kind: 'parse',
107
+ adapterId: id,
108
+ seriesRef,
109
+ detail: `response is not the expected JSONP wrapper (${text.slice(0, 60).replace(/\s+/g, ' ')})`,
110
+ })
111
+ }
112
+ try {
113
+ return JSON.parse(text.slice(start + 1, end))
114
+ } catch {
115
+ throw new SourceError({
116
+ kind: 'parse',
117
+ adapterId: id,
118
+ seriesRef,
119
+ detail: `the JSONP payload is not valid JSON (${text.slice(start + 1, start + 60).replace(/\s+/g, ' ')})`,
120
+ })
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Parse a kline body into ascending daily bars (the close is 'v').
126
+ *
127
+ * @param {string} body - response body.
128
+ * @param {string} seriesRef - symbol.
129
+ * @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
130
+ */
131
+ export function parseKline(body, seriesRef) {
132
+ const payload = parseJsonp(body, seriesRef)
133
+ if (payload === null || payload === undefined) {
134
+ throw new SourceError({
135
+ kind: 'unsupported',
136
+ adapterId: id,
137
+ seriesRef,
138
+ detail: `the endpoint does not serve ${seriesRef} (answered null)`,
139
+ })
140
+ }
141
+ if (!Array.isArray(payload)) {
142
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'the JSONP payload is not an array' })
143
+ }
144
+ if (payload.length === 0) {
145
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the symbol returned zero bars' })
146
+ }
147
+ const points = []
148
+ for (const row of payload) {
149
+ if (row === null || typeof row !== 'object') continue
150
+ const date = row.day
151
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
152
+ const value = parseNumber(row.close)
153
+ if (value === undefined) continue
154
+ // The endpoint returns a full A-share bar per trading day; the close is the
155
+ // observation and open/high/low ride along for the candle view.
156
+ points.push(withBar(
157
+ { t: date, v: value },
158
+ { o: parseNumber(row.open), h: parseNumber(row.high), l: parseNumber(row.low) },
159
+ ))
160
+ }
161
+ if (points.length === 0) {
162
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'no usable bars after filtering' })
163
+ }
164
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
165
+ return { name: SYMBOLS[seriesRef]?.name ?? seriesRef, points }
166
+ }
167
+
168
+ /**
169
+ * Fetch and normalize one index series.
170
+ *
171
+ * @param {{ seriesRef: string, params?: { count?: 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
+ if (SYMBOLS[seriesRef] === undefined) {
178
+ throw new SourceError({
179
+ kind: 'unsupported',
180
+ adapterId: id,
181
+ seriesRef,
182
+ detail: `symbol ${JSON.stringify(seriesRef)} is not one of the verified indices (${Object.keys(SYMBOLS).join(', ')})`,
183
+ })
184
+ }
185
+ const url = buildUrl(seriesRef, params ?? {})
186
+ const { body } = await getText({ fetch: deps.fetch, url, adapterId: id, seriesRef, signal: deps.signal })
187
+ const { name, points } = parseKline(body, seriesRef)
188
+ return {
189
+ adapterId: id,
190
+ seriesRef,
191
+ points,
192
+ meta: { name, unit: '点', freq: 'daily' },
193
+ fetchedAt: deps.clock.now().toISOString(),
194
+ sourceRef: sourceRef(seriesRef),
195
+ raw: { url, bars: points.length },
196
+ }
197
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * 新浪美股指数 adapter — daily history for the US equity indices.
3
+ *
4
+ * Used because 东方财富's quote host resets connections from this network and
5
+ * FRED's equity-index series are unreachable here. The endpoint is the same one
6
+ * Sina's own US index pages use, and it returns full daily history (2004 onward)
7
+ * for the four major US indices.
8
+ *
9
+ * Response is a JSONP wrapper around a plain array:
10
+ *
11
+ * x([{"d":"2026-09-11","o":"52204.46","h":"52720.24","l":"52204.46","c":"52573.29", ...}])
12
+ *
13
+ * Traps this adapter contains:
14
+ * - the body is **JSONP**, not JSON: the wrapper must be stripped before parsing;
15
+ * - the payload is GBK-encoded in some mirrors, so the raw bytes are decoded
16
+ * defensively and non-UTF-8 bytes are replaced rather than throwing;
17
+ * - 'c' (close) is a string, and the rows arrive ascending;
18
+ * - the numeric index symbols ('.N225', '.GDAXI', …) that look plausible return
19
+ * an error for this endpoint, so only the verified set is allowed through.
20
+ *
21
+ * @module sources/sina-us
22
+ */
23
+ import { SourceError } from '../core/types.js'
24
+ import { parseNumber } from '../core/stats/series.js'
25
+ import { getText } from './http.js'
26
+ import { withBar } from './ohlc.js'
27
+
28
+ /** @type {string} */
29
+ export const id = 'sina-us'
30
+
31
+ /** @type {string} */
32
+ export const label = '新浪财经'
33
+
34
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
35
+ export const capabilities = [{ kinds: ['equity'], frequencies: ['daily'] }]
36
+
37
+ /**
38
+ * Today's daily bars only: this endpoint has no resampling parameter and ignores
39
+ * one, so the panel must not offer a period switch for these series.
40
+ */
41
+ export const barSizes = false
42
+
43
+ /**
44
+ * The symbols this adapter serves, each verified live.
45
+ *
46
+ * @type {Record<string, { name: string, page: string }>}
47
+ */
48
+ export const SYMBOLS = {
49
+ '.DJI': { name: '道琼斯工业指数', page: 'https://finance.sina.com.cn/stock/usstock/' },
50
+ '.IXIC': { name: '纳斯达克综合指数', page: 'https://finance.sina.com.cn/stock/usstock/' },
51
+ '.INX': { name: '标普 500', page: 'https://finance.sina.com.cn/stock/usstock/' },
52
+ '.NDX': { name: '纳斯达克 100', page: 'https://finance.sina.com.cn/stock/usstock/' },
53
+ }
54
+
55
+ /**
56
+ * Build the JSONP URL for one symbol.
57
+ *
58
+ * @param {string} seriesRef - Sina US symbol, e.g. '.DJI'.
59
+ * @returns {string} request URL.
60
+ */
61
+ export function buildUrl(seriesRef) {
62
+ return `https://stock.finance.sina.com.cn/usstock/api/jsonp.php/x/US_MinKService.getDailyK?symbol=${encodeURIComponent(seriesRef)}&___qn=3`
63
+ }
64
+
65
+ /**
66
+ * Source reference for one symbol.
67
+ *
68
+ * @param {string} seriesRef - Sina US symbol.
69
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
70
+ */
71
+ export function sourceRef(seriesRef) {
72
+ const entry = SYMBOLS[seriesRef]
73
+ return {
74
+ adapterId: id,
75
+ url: entry?.page ?? 'https://finance.sina.com.cn/stock/usstock/',
76
+ apiUrl: buildUrl(seriesRef),
77
+ seriesRef,
78
+ label,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Strip the JSONP wrapper and parse the array.
84
+ *
85
+ * @param {string} body - response body.
86
+ * @param {string} seriesRef - symbol (for errors).
87
+ * @returns {any[]} parsed rows.
88
+ */
89
+ export function parseJsonp(body, seriesRef) {
90
+ const text = String(body ?? '')
91
+ const start = text.indexOf('(')
92
+ const end = text.lastIndexOf(')')
93
+ if (start === -1 || end === -1 || end <= start) {
94
+ // A wrapper-less body is accepted too: some mirrors answer with bare JSON.
95
+ try {
96
+ const parsed = JSON.parse(text)
97
+ if (Array.isArray(parsed)) return parsed
98
+ } catch {
99
+ // fall through to the classified error
100
+ }
101
+ throw new SourceError({
102
+ kind: 'parse',
103
+ adapterId: id,
104
+ seriesRef,
105
+ detail: `response is not the expected JSONP wrapper (${text.slice(0, 60).replace(/\s+/g, ' ')})`,
106
+ })
107
+ }
108
+ const inner = text.slice(start + 1, end)
109
+ try {
110
+ const parsed = JSON.parse(inner)
111
+ if (!Array.isArray(parsed)) {
112
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'the JSONP payload is not an array' })
113
+ }
114
+ return parsed
115
+ } catch (error) {
116
+ if (error instanceof SourceError) throw error
117
+ throw new SourceError({
118
+ kind: 'parse',
119
+ adapterId: id,
120
+ seriesRef,
121
+ detail: `the JSONP payload is not valid JSON (${String(inner).slice(0, 60).replace(/\s+/g, ' ')})`,
122
+ })
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Parse a US daily kline body into ascending closes.
128
+ *
129
+ * @param {string} body - response body.
130
+ * @param {string} seriesRef - symbol.
131
+ * @returns {{ name: string, points: Array<{ t: string, v: number }> }} parsed series.
132
+ */
133
+ export function parseDailyK(body, seriesRef) {
134
+ const rows = parseJsonp(body, seriesRef)
135
+ if (rows.length === 0) {
136
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the symbol returned zero rows' })
137
+ }
138
+ const points = []
139
+ for (const row of rows) {
140
+ if (row === null || typeof row !== 'object') continue
141
+ const date = row.d
142
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
143
+ const value = parseNumber(row.c)
144
+ if (value === undefined) continue
145
+ // The endpoint returns a full bar per day; the close is the observation and
146
+ // open/high/low are carried for the candlestick view. An incoherent bar (the
147
+ // live payload has one) is dropped here rather than forwarded: the close
148
+ // survives, the impossible candle never reaches the chart.
149
+ points.push(withBar({ t: date, v: value }, { o: parseNumber(row.o), h: parseNumber(row.h), l: parseNumber(row.l) }))
150
+ }
151
+ if (points.length === 0) {
152
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'no usable rows after filtering' })
153
+ }
154
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
155
+ return { name: SYMBOLS[seriesRef]?.name ?? seriesRef, points }
156
+ }
157
+
158
+ /**
159
+ * Fetch and normalize one US index series.
160
+ *
161
+ * @param {{ seriesRef: string, params?: object }} req - request.
162
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
163
+ * @returns {Promise<object>} normalized 'RawSeries'.
164
+ */
165
+ export async function fetchSeries(req, deps) {
166
+ const { seriesRef } = req
167
+ if (SYMBOLS[seriesRef] === undefined) {
168
+ throw new SourceError({
169
+ kind: 'unsupported',
170
+ adapterId: id,
171
+ seriesRef,
172
+ detail: `symbol ${JSON.stringify(seriesRef)} is not one of the verified indices (${Object.keys(SYMBOLS).join(', ')})`,
173
+ })
174
+ }
175
+ const url = buildUrl(seriesRef)
176
+ const { body } = await getText({ fetch: deps.fetch, url, adapterId: id, seriesRef, signal: deps.signal })
177
+ const { name, points } = parseDailyK(body, seriesRef)
178
+ return {
179
+ adapterId: id,
180
+ seriesRef,
181
+ points,
182
+ meta: { name, unit: '点', freq: 'daily' },
183
+ fetchedAt: deps.clock.now().toISOString(),
184
+ sourceRef: sourceRef(seriesRef),
185
+ raw: { url, rows: points.length },
186
+ }
187
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * 腾讯行情 adapter — daily kline for Chinese, Hong Kong and bond indices.
3
+ *
4
+ * Used as the quote source for this deployment because 东方财富's 'push2his'
5
+ * host resets connections from this network (its sibling 'datacenter-web' host
6
+ * works, so it is host-specific blocking rather than a credential problem).
7
+ *
8
+ * Response shape (verified live):
9
+ *
10
+ * {"code":0,"data":{"sh000001":{"day":[["2026-09-11","3910.920","3888.110","3912.320","3852.030","579123145.000"]],"qt":{...}}}}
11
+ *
12
+ * Trap this adapter contains: within a 'day' row the column order is
13
+ * '[date, open, close, high, low, volume]' — **close is the third column**, which
14
+ * is not the order most vendors use. A test pins it against a recorded fixture
15
+ * whose values were checked against the published close.
16
+ *
17
+ * @module sources/tencent
18
+ */
19
+ import { SourceError } from '../core/types.js'
20
+ import { parseNumber } from '../core/stats/series.js'
21
+ import { getText } from './http.js'
22
+
23
+ /** @type {string} */
24
+ export const id = 'tencent'
25
+
26
+ /** @type {string} */
27
+ export const label = '腾讯行情'
28
+
29
+ /** @type {Array<{ kinds: string[], frequencies: string[] }>} */
30
+ export const capabilities = [{ kinds: ['equity', 'bond'], frequencies: ['daily'] }]
31
+
32
+ /** Index of the close inside a kline row: '[date, open, close, high, low, volume]'. */
33
+ export const CLOSE_INDEX = 2
34
+
35
+ /** Default bar count; ~6 years of trading days. */
36
+ const DEFAULT_LIMIT = 1600
37
+
38
+ /**
39
+ * Build the kline URL for one symbol.
40
+ *
41
+ * @param {string} seriesRef - Tencent symbol, e.g. 'sh000001'.
42
+ * @param {{ count?: number }} [params] - catalog params.
43
+ * @returns {string} request URL.
44
+ */
45
+ export function buildUrl(seriesRef, params = {}) {
46
+ const count = params.count ?? DEFAULT_LIMIT
47
+ return `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${encodeURIComponent(seriesRef)},day,,,${count},qfq`
48
+ }
49
+
50
+ /**
51
+ * Human landing page for a symbol.
52
+ *
53
+ * @param {string} seriesRef - Tencent symbol.
54
+ * @returns {string} page URL.
55
+ */
56
+ export function pageUrl(seriesRef) {
57
+ if (seriesRef.startsWith('hk')) return 'https://gu.qq.com/hkHSI'
58
+ if (seriesRef.startsWith('sh') || seriesRef.startsWith('sz')) return `https://gu.qq.com/${seriesRef}/zs`
59
+ return 'https://gu.qq.com/'
60
+ }
61
+
62
+ /**
63
+ * Source reference for one symbol.
64
+ *
65
+ * @param {string} seriesRef - Tencent symbol.
66
+ * @returns {{ adapterId: string, url: string, apiUrl: string, seriesRef: string, label: string }} source reference.
67
+ */
68
+ export function sourceRef(seriesRef) {
69
+ return { adapterId: id, url: pageUrl(seriesRef), apiUrl: buildUrl(seriesRef), seriesRef, label }
70
+ }
71
+
72
+ /**
73
+ * Parse a kline payload into ascending close prices.
74
+ *
75
+ * @param {string} body - response body.
76
+ * @param {string} seriesRef - symbol (for errors).
77
+ * @returns {{ name: string|undefined, points: Array<{ t: string, v: number }> }} parsed series.
78
+ */
79
+ export function parseKline(body, seriesRef) {
80
+ let payload
81
+ try {
82
+ payload = JSON.parse(body)
83
+ } catch {
84
+ throw new SourceError({
85
+ kind: 'parse',
86
+ adapterId: id,
87
+ seriesRef,
88
+ detail: `response is not JSON (${String(body).slice(0, 60).replace(/\s+/g, ' ')})`,
89
+ })
90
+ }
91
+ if (payload === null || typeof payload !== 'object') {
92
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'response is not a JSON object' })
93
+ }
94
+ if (payload.code !== 0) {
95
+ throw new SourceError({
96
+ kind: 'unsupported',
97
+ adapterId: id,
98
+ seriesRef,
99
+ detail: `upstream rejected the symbol (code=${payload.code}, msg=${payload.msg ?? ''})`,
100
+ })
101
+ }
102
+ const node = payload.data?.[seriesRef]
103
+ if (node === null || node === undefined) {
104
+ throw new SourceError({ kind: 'unsupported', adapterId: id, seriesRef, detail: `no data node for ${seriesRef}` })
105
+ }
106
+ const rows = node.qfqday ?? node.day
107
+ if (!Array.isArray(rows)) {
108
+ throw new SourceError({ kind: 'parse', adapterId: id, seriesRef, detail: 'the kline list is missing from the response' })
109
+ }
110
+ if (rows.length === 0) {
111
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'the symbol returned zero bars' })
112
+ }
113
+ const points = []
114
+ for (const row of rows) {
115
+ if (!Array.isArray(row) || row.length <= CLOSE_INDEX) {
116
+ throw new SourceError({
117
+ kind: 'parse',
118
+ adapterId: id,
119
+ seriesRef,
120
+ detail: `a kline row has ${Array.isArray(row) ? row.length : 'no'} columns; expected at least ${CLOSE_INDEX + 1}`,
121
+ })
122
+ }
123
+ const date = String(row[0])
124
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
125
+ const value = parseNumber(row[CLOSE_INDEX])
126
+ if (value === undefined) continue
127
+ points.push({ t: date, v: value })
128
+ }
129
+ if (points.length === 0) {
130
+ throw new SourceError({ kind: 'empty', adapterId: id, seriesRef, detail: 'no usable bars after filtering' })
131
+ }
132
+ points.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
133
+ const name = Array.isArray(node.qt?.[seriesRef]) ? String(node.qt[seriesRef][1] ?? '') : undefined
134
+ return { name: name === '' ? undefined : name, points }
135
+ }
136
+
137
+ /**
138
+ * Fetch and normalize one index series.
139
+ *
140
+ * @param {{ seriesRef: string, params?: { count?: number } }} req - request.
141
+ * @param {{ fetch: Function, clock: { now: () => Date }, signal?: AbortSignal }} deps - injected dependencies.
142
+ * @returns {Promise<object>} normalized 'RawSeries'.
143
+ */
144
+ export async function fetchSeries(req, deps) {
145
+ const { seriesRef, params } = req
146
+ const url = buildUrl(seriesRef, params ?? {})
147
+ const { body } = await getText({ fetch: deps.fetch, url, adapterId: id, seriesRef, signal: deps.signal })
148
+ const { name, points } = parseKline(body, seriesRef)
149
+ return {
150
+ adapterId: id,
151
+ seriesRef,
152
+ points,
153
+ meta: { name: name ?? seriesRef, unit: '点', freq: 'daily' },
154
+ fetchedAt: deps.clock.now().toISOString(),
155
+ sourceRef: sourceRef(seriesRef),
156
+ raw: { url, bars: points.length },
157
+ }
158
+ }