@v587d/capital-generation 0.0.0-stage → 2.6.1
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/CHANGELOG.md +814 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/cg-icon.png +0 -0
- package/capital-config/client.js +28 -0
- package/capital-config/client.src.cjs +388 -0
- package/capital-config/index.js +113 -0
- package/capital-config/package.json +36 -0
- package/capital-watchlist/client.js +182 -0
- package/capital-watchlist/client.src.cjs +1229 -0
- package/capital-watchlist/index.js +772 -0
- package/capital-watchlist/package.json +37 -0
- package/chart-ui/README.md +112 -0
- package/chart-ui/client.js +16 -0
- package/chart-ui/client.src.cjs +126 -0
- package/chart-ui/index.js +205 -0
- package/chart-ui/package.json +30 -0
- package/cordis.patch.yml +72 -0
- package/lib/agents/bash-guard.d.ts +55 -0
- package/lib/agents/bash-guard.js +162 -0
- package/lib/agents/root-tool-policy.d.ts +164 -0
- package/lib/agents/root-tool-policy.js +309 -0
- package/lib/chart/artifact-ref.d.ts +65 -0
- package/lib/chart/artifact-ref.js +107 -0
- package/lib/chart/downsample.d.ts +8 -0
- package/lib/chart/downsample.js +53 -0
- package/lib/chart/errors.d.ts +16 -0
- package/lib/chart/errors.js +15 -0
- package/lib/chart/events.d.ts +120 -0
- package/lib/chart/events.js +471 -0
- package/lib/chart/html.d.ts +21 -0
- package/lib/chart/html.js +154 -0
- package/lib/chart/runtime.d.ts +20 -0
- package/lib/chart/runtime.js +270 -0
- package/lib/chart/series.d.ts +102 -0
- package/lib/chart/series.js +424 -0
- package/lib/chart/source-token.d.ts +64 -0
- package/lib/chart/source-token.js +217 -0
- package/lib/chart/spec.d.ts +58 -0
- package/lib/chart/spec.js +265 -0
- package/lib/chart/tool.d.ts +86 -0
- package/lib/chart/tool.js +288 -0
- package/lib/chart/vendor/LICENSE +201 -0
- package/lib/chart/vendor/VERSION.json +8 -0
- package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
- package/lib/data-collector/args.d.ts +38 -0
- package/lib/data-collector/args.js +114 -0
- package/lib/data-collector/dataset-tools.d.ts +3 -0
- package/lib/data-collector/dataset-tools.js +421 -0
- package/lib/data-collector/describe.d.ts +99 -0
- package/lib/data-collector/describe.js +256 -0
- package/lib/data-collector/hub.d.ts +187 -0
- package/lib/data-collector/hub.js +469 -0
- package/lib/data-collector/query-time.d.ts +58 -0
- package/lib/data-collector/query-time.js +101 -0
- package/lib/data-collector/query.d.ts +57 -0
- package/lib/data-collector/query.js +473 -0
- package/lib/data-collector/store.d.ts +499 -0
- package/lib/data-collector/store.js +1486 -0
- package/lib/data-collector/time-axis.d.ts +169 -0
- package/lib/data-collector/time-axis.js +419 -0
- package/lib/data-collector/tools.d.ts +32 -0
- package/lib/data-collector/tools.js +216 -0
- package/lib/index.d.ts +275 -0
- package/lib/index.js +458 -0
- package/lib/net/eastmoney-client.d.ts +104 -0
- package/lib/net/eastmoney-client.js +118 -0
- package/lib/net/throttle.d.ts +16 -0
- package/lib/net/throttle.js +34 -0
- package/lib/ocr/client.d.ts +82 -0
- package/lib/ocr/client.js +281 -0
- package/lib/ocr/markdown.d.ts +75 -0
- package/lib/ocr/markdown.js +209 -0
- package/lib/ocr/store.d.ts +119 -0
- package/lib/ocr/store.js +226 -0
- package/lib/sources/eastmoney-http.d.ts +2 -0
- package/lib/sources/eastmoney-http.js +1253 -0
- package/lib/sources/fuyao-core.d.ts +52 -0
- package/lib/sources/fuyao-core.js +96 -0
- package/lib/sources/fuyao-rest.d.ts +14 -0
- package/lib/sources/fuyao-rest.js +2057 -0
- package/lib/sources/security-code.d.ts +27 -0
- package/lib/sources/security-code.js +104 -0
- package/lib/sources/security-identity.d.ts +24 -0
- package/lib/sources/security-identity.js +89 -0
- package/lib/sources/tencent-http.d.ts +2 -0
- package/lib/sources/tencent-http.js +1053 -0
- package/lib/sources/wind-mcp.d.ts +8 -0
- package/lib/sources/wind-mcp.js +593 -0
- package/lib/time/tools.d.ts +74 -0
- package/lib/time/tools.js +464 -0
- package/lib/tool-exec.d.ts +29 -0
- package/lib/tool-exec.js +17 -0
- package/lib/web-retriever/engines.d.ts +61 -0
- package/lib/web-retriever/engines.js +202 -0
- package/lib/web-retriever/html-markdown.d.ts +50 -0
- package/lib/web-retriever/html-markdown.js +321 -0
- package/lib/web-retriever/local-fetch.d.ts +121 -0
- package/lib/web-retriever/local-fetch.js +562 -0
- package/lib/web-retriever/retriever.d.ts +58 -0
- package/lib/web-retriever/retriever.js +94 -0
- package/lib/web-retriever/sources.d.ts +235 -0
- package/lib/web-retriever/sources.js +961 -0
- package/lib/web-retriever/tools.d.ts +50 -0
- package/lib/web-retriever/tools.js +1029 -0
- package/lib/web-retriever/wind-client.d.ts +69 -0
- package/lib/web-retriever/wind-client.js +311 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +84 -4
- package/preset/capital-generation/README.md +161 -0
- package/preset/capital-generation/agent.patch.yml +509 -0
- package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
- package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
- package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
- package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
- package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import { buildDataKey } from '../data-collector/hub.js';
|
|
2
|
+
import { getDataTimeContract } from '../time/tools.js';
|
|
3
|
+
import { createRequestThrottle } from '../net/throttle.js';
|
|
4
|
+
import { isLikelyIndex, normalizeSecurityCodes, parseSecurityCode, requireTencentSecurity } from './security-code.js';
|
|
5
|
+
const QUOTE_URL = 'https://qt.gtimg.cn/q=';
|
|
6
|
+
const TICK_URL = 'https://stock.gtimg.cn/data/index.php';
|
|
7
|
+
const KLINE_HOSTS = [
|
|
8
|
+
'https://web.ifzq.gtimg.cn',
|
|
9
|
+
'https://proxy.finance.qq.com/ifzqgtimg',
|
|
10
|
+
'https://ifzq.gtimg.cn',
|
|
11
|
+
];
|
|
12
|
+
const HOST_COOLDOWN_MS = 120_000;
|
|
13
|
+
const KLINE_MINUTES = new Set(['m1', 'm5', 'm15', 'm30', 'm60']);
|
|
14
|
+
const KLINE_PERIODS = new Set(['day', 'week', 'month', 'm1', 'm5', 'm15', 'm30', 'm60']);
|
|
15
|
+
const KLINE_SPAN_DAYS = { day: 700, week: 3650, month: 18250 };
|
|
16
|
+
const MAX_TICK_PAGES = 300;
|
|
17
|
+
const TICK_SESSION_END = '15:00:59';
|
|
18
|
+
const hostDownUntil = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* 腾讯出口的**唯一**节流链(进程级)。三个端点都按 IP 风控(429 / 空 data / 静默限流),
|
|
21
|
+
* 而模型一次并发多个 quote / kline 调用会把请求成倍打出去。此前只有 `tencent_ticks` 翻页里
|
|
22
|
+
* 有一处 100ms 本地 sleep——并发调用各自 sleep,请求仍同时到达,等于没有 pacing;
|
|
23
|
+
* 现在所有出口(行情、分笔每一页、K 线的每一次换机)都过这一条链,见 `src/net/throttle.ts`。
|
|
24
|
+
* 间隔取 120ms:与原来翻页的节奏同量级,不会因为把风控间隔"调保守"而拖慢 300 页分笔下载。
|
|
25
|
+
*/
|
|
26
|
+
const TENCENT_MIN_INTERVAL_MS = 120;
|
|
27
|
+
const tencentThrottle = createRequestThrottle(TENCENT_MIN_INTERVAL_MS);
|
|
28
|
+
const quoteOutput = {
|
|
29
|
+
type: 'array',
|
|
30
|
+
items: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
code: { type: 'string' },
|
|
34
|
+
tencent_symbol: { type: 'string' },
|
|
35
|
+
name: { type: 'string' },
|
|
36
|
+
price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
37
|
+
last_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
38
|
+
open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
39
|
+
change_amt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
40
|
+
change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
41
|
+
high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
42
|
+
low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
43
|
+
amount_wan: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
44
|
+
turnover_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
45
|
+
pe_ttm: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
46
|
+
amplitude_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
47
|
+
float_mcap_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
48
|
+
mcap_yi: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
49
|
+
pb: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
50
|
+
limit_up: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
51
|
+
limit_down: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
52
|
+
vol_ratio: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
53
|
+
pe_static: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
54
|
+
is_stale: { type: 'boolean' },
|
|
55
|
+
stale_reason: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
56
|
+
},
|
|
57
|
+
additionalProperties: true,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const klineOutput = {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
code: { type: 'string' },
|
|
66
|
+
tencent_symbol: { type: 'string' },
|
|
67
|
+
adjust: { type: 'string' },
|
|
68
|
+
date: { type: 'string' },
|
|
69
|
+
datetime: { type: 'string' },
|
|
70
|
+
open: { type: 'number' },
|
|
71
|
+
high: { type: 'number' },
|
|
72
|
+
low: { type: 'number' },
|
|
73
|
+
close: { type: 'number' },
|
|
74
|
+
volume: { type: 'number' },
|
|
75
|
+
turnover_rate_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
76
|
+
},
|
|
77
|
+
additionalProperties: true,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const ticksOutput = {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
date: { type: 'string' },
|
|
84
|
+
code: { type: 'string' },
|
|
85
|
+
tencent_symbol: { type: 'string' },
|
|
86
|
+
rows: {
|
|
87
|
+
type: 'array',
|
|
88
|
+
items: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
time: { type: 'string' },
|
|
92
|
+
seq: { type: 'integer' },
|
|
93
|
+
price: { type: 'number' },
|
|
94
|
+
change: { type: 'number' },
|
|
95
|
+
volume: { type: 'number' },
|
|
96
|
+
amount: { type: 'number' },
|
|
97
|
+
side: { enum: ['B', 'S', 'M'] },
|
|
98
|
+
},
|
|
99
|
+
additionalProperties: true,
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
missing_seq: { type: 'array', items: { type: 'integer' } },
|
|
103
|
+
},
|
|
104
|
+
required: ['date', 'code', 'tencent_symbol', 'rows', 'missing_seq'],
|
|
105
|
+
additionalProperties: true,
|
|
106
|
+
};
|
|
107
|
+
function isRecord(value) {
|
|
108
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
function assertKnown(values, allowed) {
|
|
111
|
+
for (const key of Object.keys(values))
|
|
112
|
+
if (!allowed.includes(key))
|
|
113
|
+
throw new Error(`unsupported parameter: ${key}`);
|
|
114
|
+
}
|
|
115
|
+
function numberOrNull(value) {
|
|
116
|
+
if (value === undefined || value === null || value === '')
|
|
117
|
+
return null;
|
|
118
|
+
if (typeof value === 'boolean')
|
|
119
|
+
throw new Error('source returned boolean where a number was expected');
|
|
120
|
+
const number = typeof value === 'number' ? value : Number(String(value).replaceAll(',', '').trim());
|
|
121
|
+
return Number.isFinite(number) ? number : null;
|
|
122
|
+
}
|
|
123
|
+
function requiredNumber(value, field) {
|
|
124
|
+
const number = numberOrNull(value);
|
|
125
|
+
if (number === null)
|
|
126
|
+
throw new Error(`source returned an invalid ${field}`);
|
|
127
|
+
return number;
|
|
128
|
+
}
|
|
129
|
+
function strictDate(value, name) {
|
|
130
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
131
|
+
throw new Error(`parameter ${name} must be YYYY-MM-DD`);
|
|
132
|
+
const parsed = new Date(`${value}T00:00:00Z`);
|
|
133
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== value)
|
|
134
|
+
throw new Error(`parameter ${name} must be a valid YYYY-MM-DD date`);
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
function integer(value, name, min, max) {
|
|
138
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max)
|
|
139
|
+
throw new Error(`parameter ${name} must be an integer from ${min} to ${max}`);
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function sourceError(message, code = 'tencent_source_error') {
|
|
143
|
+
const error = new Error(message);
|
|
144
|
+
error.code = code;
|
|
145
|
+
return error;
|
|
146
|
+
}
|
|
147
|
+
async function readResponse(response, encoding) {
|
|
148
|
+
const bytes = await response.arrayBuffer();
|
|
149
|
+
return new TextDecoder(encoding).decode(bytes);
|
|
150
|
+
}
|
|
151
|
+
async function getText(url, signal, encoding = 'utf-8', init = {}) {
|
|
152
|
+
// 取消不排队:队首那个请求挂住时,把这次取消塞进队列等于把它吞掉(docs/dev/web-retriever.md §4.2「取消原样抛出」)。
|
|
153
|
+
signal.throwIfAborted();
|
|
154
|
+
return tencentThrottle(async () => {
|
|
155
|
+
const response = await fetch(url, {
|
|
156
|
+
...init,
|
|
157
|
+
headers: { 'User-Agent': 'Mozilla/5.0', ...(init.headers ?? {}) },
|
|
158
|
+
signal,
|
|
159
|
+
});
|
|
160
|
+
const text = await readResponse(response, encoding);
|
|
161
|
+
if (!response.ok)
|
|
162
|
+
throw sourceError(`Tencent HTTP ${response.status} for ${url}`, response.status === 429 ? 'tencent_rate_limit' : 'tencent_http_error');
|
|
163
|
+
return text;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function parseJson(text, context) {
|
|
167
|
+
if (!text.trim())
|
|
168
|
+
throw sourceError(`Tencent ${context} returned an empty response`, 'tencent_empty_response');
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(text);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
throw sourceError(`Tencent ${context} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, 'tencent_invalid_response');
|
|
175
|
+
}
|
|
176
|
+
if (!isRecord(parsed))
|
|
177
|
+
throw sourceError(`Tencent ${context} returned ${Array.isArray(parsed) ? 'an array' : typeof parsed}, expected an object`, 'tencent_invalid_response');
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
function parseCodeParams(params, name = 'code') {
|
|
181
|
+
const code = requireTencentSecurity(parseSecurityCode(params[name], name), name);
|
|
182
|
+
return { code, canonical: code.canonical };
|
|
183
|
+
}
|
|
184
|
+
function normalizeQuote(params) {
|
|
185
|
+
if (!isRecord(params))
|
|
186
|
+
throw new Error('params must be an object');
|
|
187
|
+
assertKnown(params, ['codes']);
|
|
188
|
+
const codes = normalizeSecurityCodes(params.codes, 'codes', 50).map((code) => requireTencentSecurity(code, 'codes').canonical);
|
|
189
|
+
return { codes };
|
|
190
|
+
}
|
|
191
|
+
function normalizeKline(params) {
|
|
192
|
+
if (!isRecord(params))
|
|
193
|
+
throw new Error('params must be an object');
|
|
194
|
+
assertKnown(params, ['code', 'period', 'adjust', 'start', 'end', 'count']);
|
|
195
|
+
const { code } = parseCodeParams(params);
|
|
196
|
+
if (code.market === 'BJ')
|
|
197
|
+
throw new Error('parameter code: Tencent K-line endpoint supports SH/SZ only');
|
|
198
|
+
const period = params.period === undefined ? 'day' : String(params.period).toLowerCase();
|
|
199
|
+
if (!KLINE_PERIODS.has(period))
|
|
200
|
+
throw new Error('parameter period must be day/week/month/m1/m5/m15/m30/m60');
|
|
201
|
+
const adjust = params.adjust === undefined ? undefined : String(params.adjust).toLowerCase();
|
|
202
|
+
if (adjust !== undefined && !['qfq', 'hfq', 'none'].includes(adjust))
|
|
203
|
+
throw new Error('parameter adjust must be qfq, hfq, or none');
|
|
204
|
+
const start = params.start === undefined ? undefined : strictDate(params.start, 'start');
|
|
205
|
+
const end = params.end === undefined ? undefined : strictDate(params.end, 'end');
|
|
206
|
+
if (end !== undefined && start === undefined)
|
|
207
|
+
throw new Error('parameter end requires start');
|
|
208
|
+
if (start !== undefined && end !== undefined && start > end)
|
|
209
|
+
throw new Error('parameter start must not be later than end');
|
|
210
|
+
const count = params.count === undefined ? undefined : integer(params.count, 'count', 1, KLINE_MINUTES.has(period) ? 320 : 640);
|
|
211
|
+
if (KLINE_MINUTES.has(period) && adjust !== undefined && adjust !== 'none')
|
|
212
|
+
throw new Error('parameter adjust must be none for minute K-lines');
|
|
213
|
+
if (KLINE_MINUTES.has(period) && (start !== undefined || end !== undefined))
|
|
214
|
+
throw new Error('parameter start/end are only supported for day/week/month K-lines');
|
|
215
|
+
return {
|
|
216
|
+
code: code.canonical,
|
|
217
|
+
period,
|
|
218
|
+
...(adjust === undefined ? {} : { adjust }),
|
|
219
|
+
...(start === undefined ? {} : { start }),
|
|
220
|
+
...(end === undefined ? {} : { end }),
|
|
221
|
+
...(count === undefined ? {} : { count }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function normalizeTicks(params) {
|
|
225
|
+
if (!isRecord(params))
|
|
226
|
+
throw new Error('params must be an object');
|
|
227
|
+
assertKnown(params, ['code']);
|
|
228
|
+
const { code } = parseCodeParams(params);
|
|
229
|
+
if (code.isIndex)
|
|
230
|
+
throw new Error('parameter code must be a stock or ETF, not an index');
|
|
231
|
+
if (code.market === 'BJ')
|
|
232
|
+
throw new Error('parameter code: Tencent tick endpoint supports SH/SZ only');
|
|
233
|
+
return { code: code.canonical };
|
|
234
|
+
}
|
|
235
|
+
function parseQuoteRows(text, requested) {
|
|
236
|
+
const rows = [];
|
|
237
|
+
for (const line of text.split(';')) {
|
|
238
|
+
const match = /^v_(sh|sz)(\d{6})="([^"]*)"/.exec(line.trim());
|
|
239
|
+
if (!match)
|
|
240
|
+
continue;
|
|
241
|
+
const symbol = `${match[1]}${match[2]}`;
|
|
242
|
+
const code = requested.get(symbol);
|
|
243
|
+
if (!code)
|
|
244
|
+
continue;
|
|
245
|
+
const values = match[3].split('~');
|
|
246
|
+
if (values.length < 53)
|
|
247
|
+
throw sourceError(`Tencent quote ${symbol} returned ${values.length} fields, expected at least 53`, 'tencent_invalid_response');
|
|
248
|
+
const value = (index) => numberOrNull(values[index]);
|
|
249
|
+
const row = {
|
|
250
|
+
code: code.canonical,
|
|
251
|
+
tencent_symbol: symbol,
|
|
252
|
+
name: values[1] ?? '',
|
|
253
|
+
price: value(3),
|
|
254
|
+
last_close: value(4),
|
|
255
|
+
open: value(5),
|
|
256
|
+
change_amt: value(31),
|
|
257
|
+
change_pct: value(32),
|
|
258
|
+
high: value(33),
|
|
259
|
+
low: value(34),
|
|
260
|
+
amount_wan: value(37),
|
|
261
|
+
turnover_pct: value(38),
|
|
262
|
+
pe_ttm: value(39),
|
|
263
|
+
amplitude_pct: value(43),
|
|
264
|
+
float_mcap_yi: value(44),
|
|
265
|
+
mcap_yi: value(45),
|
|
266
|
+
pb: value(46),
|
|
267
|
+
limit_up: value(47),
|
|
268
|
+
limit_down: value(48),
|
|
269
|
+
vol_ratio: value(49),
|
|
270
|
+
pe_static: value(52),
|
|
271
|
+
};
|
|
272
|
+
const stale = row.amount_wan === 0 && row.price !== null && row.price === row.last_close;
|
|
273
|
+
row.is_stale = stale;
|
|
274
|
+
row.stale_reason = stale
|
|
275
|
+
? (code.market === 'BJ' && /^(43|83|87)/.test(code.digits) ? 'possible migrated BJ legacy code' : 'zero turnover and price equals previous close')
|
|
276
|
+
: null;
|
|
277
|
+
rows.push(row);
|
|
278
|
+
}
|
|
279
|
+
return rows;
|
|
280
|
+
}
|
|
281
|
+
async function executeQuote(params, signal) {
|
|
282
|
+
const codes = normalizeSecurityCodes(params.codes, 'codes', 50).map((code) => requireTencentSecurity(code, 'codes'));
|
|
283
|
+
const requested = new Map(codes.map((code) => [code.tencentSymbol, code]));
|
|
284
|
+
const text = await getText(`${QUOTE_URL}${codes.map((code) => code.tencentSymbol).join(',')}`, signal, 'gbk');
|
|
285
|
+
const rows = parseQuoteRows(text, requested);
|
|
286
|
+
if (rows.length === 0) {
|
|
287
|
+
if (text.includes('v_pv_none_match'))
|
|
288
|
+
throw new Error('Tencent returned no matching securities');
|
|
289
|
+
throw sourceError('Tencent quote returned no usable rows', 'tencent_empty_response');
|
|
290
|
+
}
|
|
291
|
+
return { data: rows, schema: quoteOutput };
|
|
292
|
+
}
|
|
293
|
+
function defaultParamRejection(payload) {
|
|
294
|
+
return payload.msg === 'param error';
|
|
295
|
+
}
|
|
296
|
+
async function callKlineHosts(transport, signal) {
|
|
297
|
+
const errors = [];
|
|
298
|
+
const rejected = transport.isParamRejection ?? defaultParamRejection;
|
|
299
|
+
for (const host of KLINE_HOSTS) {
|
|
300
|
+
if ((hostDownUntil.get(host) ?? 0) > Date.now())
|
|
301
|
+
continue;
|
|
302
|
+
try {
|
|
303
|
+
const url = `${host}${transport.path}?param=${encodeURIComponent(transport.param)}`;
|
|
304
|
+
const payload = parseJson(await getText(url, signal), `${transport.label} ${host}`);
|
|
305
|
+
if (rejected(payload)) {
|
|
306
|
+
throw sourceError(`Tencent rejected the ${transport.label} parameter (${String(payload.msg ?? '') || `code ${String(payload.code)}`}): ${transport.param}`, 'tencent_invalid_param');
|
|
307
|
+
}
|
|
308
|
+
if (isRecord(payload.data) && !transport.isEmpty(payload.data))
|
|
309
|
+
return payload.data;
|
|
310
|
+
errors.push(`${host}: empty data`);
|
|
311
|
+
hostDownUntil.set(host, Date.now() + HOST_COOLDOWN_MS);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
// 取消与参数性失败都原样抛出,**先于**一切拉黑/换机逻辑(docs/dev/web-retriever.md §4.2「取消原样抛出」)。
|
|
315
|
+
if (error instanceof Error && error.name === 'AbortError')
|
|
316
|
+
throw error;
|
|
317
|
+
if (error.code === 'tencent_invalid_param')
|
|
318
|
+
throw error;
|
|
319
|
+
errors.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
|
|
320
|
+
hostDownUntil.set(host, Date.now() + HOST_COOLDOWN_MS);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
throw sourceError(`Tencent ${transport.label} endpoints unavailable: ${errors.join('; ')}`, 'tencent_kline_unavailable');
|
|
324
|
+
}
|
|
325
|
+
async function callKline(path, param, signal) {
|
|
326
|
+
return { data: await callKlineHosts({ path, param, label: 'K-line', isEmpty: (data) => Object.keys(data).length === 0 }, signal) };
|
|
327
|
+
}
|
|
328
|
+
function addDays(date, days) {
|
|
329
|
+
const value = new Date(`${date}T00:00:00Z`);
|
|
330
|
+
value.setUTCDate(value.getUTCDate() + days);
|
|
331
|
+
return value.toISOString().slice(0, 10);
|
|
332
|
+
}
|
|
333
|
+
function todayUtc() {
|
|
334
|
+
return new Date().toISOString().slice(0, 10);
|
|
335
|
+
}
|
|
336
|
+
function parseKlineRows(data, symbol, canonical, period, adjust, start, end) {
|
|
337
|
+
const node = data[symbol];
|
|
338
|
+
if (!isRecord(node))
|
|
339
|
+
throw sourceError(`Tencent K-line ${symbol} response has no symbol node`, 'tencent_invalid_response');
|
|
340
|
+
const key = adjust === 'none' ? period : `${adjust}${period}`;
|
|
341
|
+
const items = node[key] ?? node[period];
|
|
342
|
+
if (!Array.isArray(items))
|
|
343
|
+
throw sourceError(`Tencent K-line ${symbol} response has no ${key}/${period} array`, 'tencent_invalid_response');
|
|
344
|
+
if (key !== period && Array.isArray(node[key]) && node[key].length === 0 && Array.isArray(node[period]) && node[period].length > 0) {
|
|
345
|
+
throw sourceError(`Tencent K-line ${symbol} returned empty ${key} beside non-empty raw ${period}`, 'tencent_invalid_response');
|
|
346
|
+
}
|
|
347
|
+
const rows = [];
|
|
348
|
+
for (const item of items) {
|
|
349
|
+
if (!Array.isArray(item) || item.length < 6)
|
|
350
|
+
throw sourceError(`Tencent K-line ${symbol} row shape changed`, 'tencent_invalid_response');
|
|
351
|
+
const date = String(item[0]);
|
|
352
|
+
if (start !== undefined && end !== undefined && (date < start || date > end))
|
|
353
|
+
throw sourceError(`Tencent K-line ${symbol} returned out-of-range date ${date}`, 'tencent_invalid_response');
|
|
354
|
+
rows.push({
|
|
355
|
+
code: canonical,
|
|
356
|
+
tencent_symbol: symbol,
|
|
357
|
+
adjust,
|
|
358
|
+
date,
|
|
359
|
+
open: requiredNumber(item[1], 'open'),
|
|
360
|
+
high: requiredNumber(item[3], 'high'),
|
|
361
|
+
low: requiredNumber(item[4], 'low'),
|
|
362
|
+
close: requiredNumber(item[2], 'close'),
|
|
363
|
+
volume: requiredNumber(item[5], 'volume'),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return rows;
|
|
367
|
+
}
|
|
368
|
+
async function executeKline(params, signal) {
|
|
369
|
+
const code = requireTencentSecurity(parseSecurityCode(params.code, 'code'), 'code');
|
|
370
|
+
const period = String(params.period ?? 'day');
|
|
371
|
+
const adjust = String(params.adjust ?? (KLINE_MINUTES.has(period) ? 'none' : 'qfq'));
|
|
372
|
+
const start = params.start;
|
|
373
|
+
const end = params.end;
|
|
374
|
+
const count = Number(params.count ?? (KLINE_MINUTES.has(period) ? 320 : 320));
|
|
375
|
+
const symbol = code.tencentSymbol;
|
|
376
|
+
if (KLINE_MINUTES.has(period)) {
|
|
377
|
+
const result = await callKline('/appstock/app/kline/mkline', `${symbol},${period},,${count}`, signal);
|
|
378
|
+
const node = result.data[symbol];
|
|
379
|
+
if (!isRecord(node) || !Array.isArray(node[period]))
|
|
380
|
+
throw sourceError(`Tencent minute K-line ${symbol} response has no ${period} array`, 'tencent_invalid_response');
|
|
381
|
+
const rows = [];
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
for (const item of node[period]) {
|
|
384
|
+
if (!Array.isArray(item) || item.length < 6)
|
|
385
|
+
throw sourceError(`Tencent minute K-line ${symbol} row shape changed`, 'tencent_invalid_response');
|
|
386
|
+
const stamp = String(item[0]);
|
|
387
|
+
if (seen.has(stamp))
|
|
388
|
+
throw sourceError(`Tencent minute K-line ${symbol} contains duplicate ${stamp}`, 'tencent_invalid_response');
|
|
389
|
+
seen.add(stamp);
|
|
390
|
+
rows.push({
|
|
391
|
+
code: code.canonical,
|
|
392
|
+
tencent_symbol: symbol,
|
|
393
|
+
adjust: 'none',
|
|
394
|
+
datetime: stamp.length === 12 ? `${stamp.slice(0, 4)}-${stamp.slice(4, 6)}-${stamp.slice(6, 8)} ${stamp.slice(8, 10)}:${stamp.slice(10, 12)}` : stamp,
|
|
395
|
+
open: requiredNumber(item[1], 'open'),
|
|
396
|
+
high: requiredNumber(item[3], 'high'),
|
|
397
|
+
low: requiredNumber(item[4], 'low'),
|
|
398
|
+
close: requiredNumber(item[2], 'close'),
|
|
399
|
+
volume: requiredNumber(item[5], 'volume'),
|
|
400
|
+
turnover_rate_pct: item.length > 7 ? (numberOrNull(item[7]) === null ? null : numberOrNull(item[7]) / 100) : null,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
if (rows.length === 0)
|
|
404
|
+
throw new Error(`Tencent minute K-line ${symbol} returned no rows`);
|
|
405
|
+
return { data: rows, schema: klineOutput };
|
|
406
|
+
}
|
|
407
|
+
const last = end ?? todayUtc();
|
|
408
|
+
const windows = [];
|
|
409
|
+
if (start !== undefined) {
|
|
410
|
+
const span = KLINE_SPAN_DAYS[period];
|
|
411
|
+
let cursor = start;
|
|
412
|
+
while (cursor <= last) {
|
|
413
|
+
const stop = cursor <= last ? (addDays(cursor, span - 1) < last ? addDays(cursor, span - 1) : last) : last;
|
|
414
|
+
windows.push([cursor, stop, 640]);
|
|
415
|
+
cursor = addDays(stop, 1);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
windows.push(['', '', count]);
|
|
420
|
+
}
|
|
421
|
+
const all = [];
|
|
422
|
+
const seen = new Set();
|
|
423
|
+
for (const [windowStart, windowEnd, windowCount] of windows) {
|
|
424
|
+
const result = await callKline('/appstock/app/fqkline/get', `${symbol},${period},${windowStart},${windowEnd},${windowCount},${adjust === 'none' ? '' : adjust}`, signal);
|
|
425
|
+
for (const row of parseKlineRows(result.data, symbol, code.canonical, period, adjust, windowStart || undefined, windowEnd || undefined)) {
|
|
426
|
+
const key = row.date;
|
|
427
|
+
if (seen.has(key))
|
|
428
|
+
throw sourceError(`Tencent K-line ${symbol} contains duplicate ${key}`, 'tencent_invalid_response');
|
|
429
|
+
seen.add(key);
|
|
430
|
+
if (Number(row.open) <= 0 || Number(row.high) <= 0 || Number(row.low) <= 0 || Number(row.close) <= 0)
|
|
431
|
+
throw sourceError(`Tencent ${adjust} K-line ${symbol} contains a non-positive price`, 'tencent_invalid_response');
|
|
432
|
+
all.push(row);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
all.sort((left, right) => String(left.date).localeCompare(String(right.date)));
|
|
436
|
+
if (all.length === 0)
|
|
437
|
+
throw new Error(`Tencent K-line ${symbol} returned no rows`);
|
|
438
|
+
return { data: all, schema: klineOutput };
|
|
439
|
+
}
|
|
440
|
+
function parseSnapshot(text, symbol) {
|
|
441
|
+
const match = new RegExp(`v_${symbol}="([^"]*)"`).exec(text);
|
|
442
|
+
if (!match)
|
|
443
|
+
throw new Error(`Tencent quote returned no snapshot for ${symbol}`);
|
|
444
|
+
const fields = match[1].split('~');
|
|
445
|
+
if (fields.length < 36 || !/^\d{14}$/.test(fields[30] ?? ''))
|
|
446
|
+
throw sourceError(`Tencent quote snapshot ${symbol} shape changed`, 'tencent_invalid_response');
|
|
447
|
+
const parts = String(fields[35]).split('/');
|
|
448
|
+
if (parts.length !== 3)
|
|
449
|
+
throw sourceError(`Tencent quote snapshot ${symbol} amount field changed`, 'tencent_invalid_response');
|
|
450
|
+
const date = `${fields[30].slice(0, 4)}-${fields[30].slice(4, 6)}-${fields[30].slice(6, 8)}`;
|
|
451
|
+
return { date, clock: fields[30].slice(8), amount: requiredNumber(parts[2], 'amount') };
|
|
452
|
+
}
|
|
453
|
+
async function executeTicks(params, signal) {
|
|
454
|
+
const code = requireTencentSecurity(parseSecurityCode(params.code, 'code'), 'code');
|
|
455
|
+
if (isLikelyIndex(code))
|
|
456
|
+
throw new Error(`parameter code ${code.canonical} is an index; Tencent ticks require a stock or ETF`);
|
|
457
|
+
const symbol = code.tencentSymbol;
|
|
458
|
+
const before = parseSnapshot(await getText(`${QUOTE_URL}${symbol}`, signal, 'gbk'), symbol);
|
|
459
|
+
if (before.amount === 0)
|
|
460
|
+
throw new Error(`${symbol} has no turnover for ${before.date}`);
|
|
461
|
+
const rows = [];
|
|
462
|
+
const missingSeq = [];
|
|
463
|
+
for (let page = 0; page < MAX_TICK_PAGES; page += 1) {
|
|
464
|
+
const url = new URL(TICK_URL);
|
|
465
|
+
url.searchParams.set('appn', 'detail');
|
|
466
|
+
url.searchParams.set('action', 'data');
|
|
467
|
+
url.searchParams.set('c', symbol);
|
|
468
|
+
url.searchParams.set('p', String(page));
|
|
469
|
+
const text = (await getText(url.toString(), signal, 'gbk')).trim();
|
|
470
|
+
if (!text)
|
|
471
|
+
break;
|
|
472
|
+
const match = new RegExp(`^v_detail_data_${symbol}=\\[(\\d+),"([^"]*)"\\];?$`).exec(text);
|
|
473
|
+
if (!match || Number(match[1]) !== page)
|
|
474
|
+
throw sourceError(`Tencent ticks ${symbol} page ${page} shape changed`, 'tencent_invalid_response');
|
|
475
|
+
if (!match[2])
|
|
476
|
+
break;
|
|
477
|
+
for (const raw of match[2].split('|')) {
|
|
478
|
+
const fields = raw.split('/');
|
|
479
|
+
if (fields.length !== 7 || !/^\d{2}:\d{2}:\d{2}$/.test(fields[1]) || !['B', 'S', 'M'].includes(fields[6]))
|
|
480
|
+
throw sourceError(`Tencent ticks ${symbol} page ${page} row shape changed`, 'tencent_invalid_response');
|
|
481
|
+
const row = {
|
|
482
|
+
time: fields[1],
|
|
483
|
+
seq: integer(Number(fields[0]), 'source seq', 0, Number.MAX_SAFE_INTEGER),
|
|
484
|
+
price: requiredNumber(fields[2], 'price'),
|
|
485
|
+
change: requiredNumber(fields[3], 'change'),
|
|
486
|
+
volume: requiredNumber(fields[4], 'volume'),
|
|
487
|
+
amount: requiredNumber(fields[5], 'amount'),
|
|
488
|
+
side: fields[6],
|
|
489
|
+
};
|
|
490
|
+
const expected = rows.length === 0 ? 0 : Number(rows[rows.length - 1].seq) + 1;
|
|
491
|
+
if (row.seq < expected || (rows.length > 0 && String(row.time) < String(rows[rows.length - 1].time)))
|
|
492
|
+
throw sourceError(`Tencent ticks ${symbol} sequence or time moved backwards`, 'tencent_invalid_response');
|
|
493
|
+
if (row.seq > expected) {
|
|
494
|
+
if (row.time <= TICK_SESSION_END)
|
|
495
|
+
throw sourceError(`Tencent ticks ${symbol} has missing continuous-session sequence ${expected}-${row.seq - 1}`, 'tencent_incomplete');
|
|
496
|
+
for (let seq = expected; seq < row.seq; seq += 1)
|
|
497
|
+
missingSeq.push(seq);
|
|
498
|
+
}
|
|
499
|
+
rows.push(row);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (rows.length === 0)
|
|
503
|
+
throw new Error(`${symbol} returned no tick rows`);
|
|
504
|
+
const after = parseSnapshot(await getText(`${QUOTE_URL}${symbol}`, signal, 'gbk'), symbol);
|
|
505
|
+
if (after.date !== before.date)
|
|
506
|
+
throw sourceError(`Tencent ticks crossed trading day ${before.date} -> ${after.date}`, 'tencent_incomplete');
|
|
507
|
+
const continuousAmount = rows.filter((row) => String(row.time) <= TICK_SESSION_END).reduce((sum, row) => sum + Number(row.amount), 0);
|
|
508
|
+
if (after.amount === before.amount && Math.abs(continuousAmount - before.amount) > before.amount * 0.001 + 1000)
|
|
509
|
+
throw sourceError(`Tencent ticks ${symbol} amount ${continuousAmount} does not match quote ${before.amount}`, 'tencent_incomplete');
|
|
510
|
+
return {
|
|
511
|
+
data: { date: before.date, code: code.canonical, tencent_symbol: symbol, rows, missing_seq: missingSeq },
|
|
512
|
+
schema: ticksOutput,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* 港美股快照与 A 股快照**不能共用一份字段表**:实测腾讯在同一个 `~` 分隔串里换了列位——
|
|
517
|
+
* 币种 A 股根本没有、港股在第 75 位、美股在第 35 位;换手率港股在第 59 位(分母是**总股本**)、
|
|
518
|
+
* 美股在第 38 位(分母是**流通股本**);成交额在港股**指数行**是万元级而个股行是元级。
|
|
519
|
+
* 所以这两张表各自映射,并且只收能用算术或量级证明的列(§10.2:口径猜错就是话说错)。
|
|
520
|
+
*/
|
|
521
|
+
const HK_QUOTE_FIELDS = 78;
|
|
522
|
+
const US_QUOTE_FIELDS = 73;
|
|
523
|
+
/** 腾讯港美股时间戳是**交易所当地时间**,实测同一份响应里 `2026-10-05 16:08:09` 与 `2026-10-05 16:08:10` 并存:分隔符按行漂移,两种都得吃。 */
|
|
524
|
+
function offshoreQuoteTime(value) {
|
|
525
|
+
const match = /^(\d{4})[-/](\d{2})[-/](\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(String(value ?? '').trim());
|
|
526
|
+
return match ? `${match[1]}-${match[2]}-${match[3]} ${match[4]}` : null;
|
|
527
|
+
}
|
|
528
|
+
/** 逐行拆出 `v_<symbol>="…"`;symbol 就是请求里写出去的那串,所以请求与回执能一对一核对。 */
|
|
529
|
+
function snapshotRows(text, requested) {
|
|
530
|
+
const rows = [];
|
|
531
|
+
for (const line of text.split(';')) {
|
|
532
|
+
const match = /^v_([\w.]+)="([^"]*)"/.exec(line.trim());
|
|
533
|
+
if (!match)
|
|
534
|
+
continue;
|
|
535
|
+
if (!requested.has(match[1].toLowerCase()))
|
|
536
|
+
continue;
|
|
537
|
+
rows.push({ symbol: match[1], fields: match[2].split('~') });
|
|
538
|
+
}
|
|
539
|
+
return rows;
|
|
540
|
+
}
|
|
541
|
+
function parseHkQuoteRows(text, requested) {
|
|
542
|
+
const rows = [];
|
|
543
|
+
for (const { symbol, fields } of snapshotRows(text, requested)) {
|
|
544
|
+
if (fields.length < HK_QUOTE_FIELDS)
|
|
545
|
+
throw sourceError(`Tencent HK quote ${symbol} returned ${fields.length} fields, expected at least ${HK_QUOTE_FIELDS}`, 'tencent_invalid_response');
|
|
546
|
+
// 只收个股:指数行的第 6/37 位在实测里既不是"股"也不是"港元"(hkHSI 两列相等、量级像万元),
|
|
547
|
+
// 两种量纲不许进同一列;参数层已经拒掉字母指数,这里再核对一次上游没把行串错。
|
|
548
|
+
if (!/^\d{5}$/.test(fields[2] ?? ''))
|
|
549
|
+
throw sourceError(`Tencent HK quote ${symbol} is not a five-digit stock code (got ${JSON.stringify(fields[2] ?? '')})`, 'tencent_invalid_response');
|
|
550
|
+
const quoteTime = offshoreQuoteTime(fields[30]);
|
|
551
|
+
if (quoteTime === null)
|
|
552
|
+
throw sourceError(`Tencent HK quote ${symbol} has an unreadable timestamp ${JSON.stringify(fields[30] ?? '')}`, 'tencent_invalid_response');
|
|
553
|
+
const value = (index) => numberOrNull(fields[index]);
|
|
554
|
+
const row = {
|
|
555
|
+
code: fields[2],
|
|
556
|
+
tencent_symbol: `hk${fields[2]}`,
|
|
557
|
+
name: fields[1] ?? '',
|
|
558
|
+
english_name: fields[46] ?? '',
|
|
559
|
+
currency: fields[75] ?? '',
|
|
560
|
+
price: value(3),
|
|
561
|
+
last_close: value(4),
|
|
562
|
+
open: value(5),
|
|
563
|
+
high: value(33),
|
|
564
|
+
low: value(34),
|
|
565
|
+
change_amt: value(31),
|
|
566
|
+
change_pct: value(32),
|
|
567
|
+
amplitude_pct: value(43),
|
|
568
|
+
volume_shares: value(6),
|
|
569
|
+
amount_hkd: value(37),
|
|
570
|
+
avg_price: value(73),
|
|
571
|
+
turnover_pct: value(59),
|
|
572
|
+
total_shares: value(69),
|
|
573
|
+
market_cap_yi_hkd: value(45),
|
|
574
|
+
board_lot_shares: value(60),
|
|
575
|
+
quote_time: quoteTime,
|
|
576
|
+
};
|
|
577
|
+
const stale = row.amount_hkd === 0 && row.price !== null && row.price === row.last_close;
|
|
578
|
+
row.is_stale = stale;
|
|
579
|
+
row.stale_reason = stale ? 'zero turnover and price equals previous close' : null;
|
|
580
|
+
rows.push(row);
|
|
581
|
+
}
|
|
582
|
+
return rows;
|
|
583
|
+
}
|
|
584
|
+
function parseUsQuoteRows(text, requested) {
|
|
585
|
+
const rows = [];
|
|
586
|
+
for (const { symbol, fields } of snapshotRows(text, requested)) {
|
|
587
|
+
if (fields.length < US_QUOTE_FIELDS)
|
|
588
|
+
throw sourceError(`Tencent US quote ${symbol} returned ${fields.length} fields, expected at least ${US_QUOTE_FIELDS}`, 'tencent_invalid_response');
|
|
589
|
+
// 上游在 2 号位回交易所后缀(AAPL.OQ / BABA.N),这是 K 线入口要用的身份,缺了就无法核对。
|
|
590
|
+
// 交易所后缀实测有**一个字母**的(NYSE:BABA.N / BRK.B.N),把下限写成 2 会把真代码判成畸形。
|
|
591
|
+
if (!/^[A-Z][A-Z.]{0,9}\.[A-Z]{1,3}$/.test(fields[2] ?? ''))
|
|
592
|
+
throw sourceError(`Tencent US quote ${symbol} has an unexpected code ${JSON.stringify(fields[2] ?? '')}`, 'tencent_invalid_response');
|
|
593
|
+
const quoteTime = offshoreQuoteTime(fields[30]);
|
|
594
|
+
if (quoteTime === null)
|
|
595
|
+
throw sourceError(`Tencent US quote ${symbol} has an unreadable timestamp ${JSON.stringify(fields[30] ?? '')}`, 'tencent_invalid_response');
|
|
596
|
+
const value = (index) => numberOrNull(fields[index]);
|
|
597
|
+
const row = {
|
|
598
|
+
code: fields[2],
|
|
599
|
+
tencent_symbol: `us${fields[2]}`,
|
|
600
|
+
exchange_code: fields[2].slice(fields[2].lastIndexOf('.') + 1),
|
|
601
|
+
name: fields[1] ?? '',
|
|
602
|
+
english_name: fields[46] ?? '',
|
|
603
|
+
currency: fields[35] ?? '',
|
|
604
|
+
price: value(3),
|
|
605
|
+
last_close: value(4),
|
|
606
|
+
open: value(5),
|
|
607
|
+
high: value(33),
|
|
608
|
+
low: value(34),
|
|
609
|
+
change_amt: value(31),
|
|
610
|
+
change_pct: value(32),
|
|
611
|
+
amplitude_pct: value(43),
|
|
612
|
+
volume_shares: value(6),
|
|
613
|
+
amount_usd: value(37),
|
|
614
|
+
avg_price: value(67),
|
|
615
|
+
turnover_pct: value(38),
|
|
616
|
+
total_shares: value(62),
|
|
617
|
+
float_shares: value(63),
|
|
618
|
+
float_market_cap_yi_usd: value(44),
|
|
619
|
+
market_cap_yi_usd: value(45),
|
|
620
|
+
quote_time: quoteTime,
|
|
621
|
+
};
|
|
622
|
+
const stale = row.amount_usd === 0 && row.price !== null && row.price === row.last_close;
|
|
623
|
+
row.is_stale = stale;
|
|
624
|
+
row.stale_reason = stale ? 'zero turnover and price equals previous close' : null;
|
|
625
|
+
// symbol 是"哪个写法换来了这一行",只给上面的重试判定用,不出现在行里。
|
|
626
|
+
rows.push({ symbol, row });
|
|
627
|
+
}
|
|
628
|
+
return rows;
|
|
629
|
+
}
|
|
630
|
+
/** 港美股代码:HK 是 5 位数字(实测 `hk700`/`hk0700` 被上游静默丢掉,必须左补零),指数别名一律拒收。 */
|
|
631
|
+
function normalizeHkCode(value, name) {
|
|
632
|
+
const raw = String(value ?? '').trim().toUpperCase();
|
|
633
|
+
const digits = /^(?:HK)?(\d{1,5})(?:\.HK)?$/.exec(raw)?.[1];
|
|
634
|
+
if (!digits)
|
|
635
|
+
throw new Error(`parameter ${name} must be a five-digit Hong Kong stock code (for example 00700, hk00700 or 700); HK index codes such as HSI/HSTECH are not supported here`);
|
|
636
|
+
return digits.padStart(5, '0');
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* 美股写法照原样收下(`AAPL` / `BRK.B` / `BABA.N` / `AAPL.OQ` 都是真实存在的说法),
|
|
640
|
+
* 消歧交给上游(见 `usSymbolVariants`):实测腾讯**快照只认不带后缀的写法**(`usBABA.N` 整行不回,
|
|
641
|
+
* `usBABA` 认),而 K 线只认**带正确后缀**的写法(`usAAPL` 回一列 2011 年的陌生序列、
|
|
642
|
+
* `usAAPL.NS` 静默 0 行)。点号既可能是级别码(BRK.B / BF.B)也可能是交易所后缀(.N / .OQ),
|
|
643
|
+
* 从字面上分不出来——所以不猜。
|
|
644
|
+
*/
|
|
645
|
+
function normalizeUsCode(value, name) {
|
|
646
|
+
const raw = String(value ?? '').trim().toUpperCase();
|
|
647
|
+
const match = /^(?:US)?([A-Z]{1,6}(?:\.[A-Z]{1,3}){0,2})$/.exec(raw);
|
|
648
|
+
if (!match)
|
|
649
|
+
throw new Error(`parameter ${name} must be a US equity ticker such as AAPL, BRK.B or BABA.N (a Tencent exchange suffix like .OQ is accepted and re-resolved from the snapshot)`);
|
|
650
|
+
// 两位以上的点号尾段(.OQ/.AM/.NS)在腾讯那里一定是交易所后缀,直接剥掉,于是 AAPL 与 AAPL.OQ
|
|
651
|
+
// 是同一只票、只请求一次。**单字母尾段**(BRK.B 的级别码 vs BABA.N 的交易所码)字面上分不开,
|
|
652
|
+
// 就照原样留着,交给上游逐个写法裁决——猜错一次只是多一轮请求,猜错第二次就是把数据挂错标的。
|
|
653
|
+
return match[1].replace(/\.[A-Z]{2,3}$/u, '');
|
|
654
|
+
}
|
|
655
|
+
/** 依次尝试的写法:原样 → 每次剥掉最后一段点号,最多剥两段(BRK.B.N → BRK.B → BRK)。 */
|
|
656
|
+
function usSymbolVariants(ticker) {
|
|
657
|
+
const variants = [`us${ticker}`];
|
|
658
|
+
let current = ticker;
|
|
659
|
+
for (let depth = 0; depth < 2; depth += 1) {
|
|
660
|
+
const dot = current.lastIndexOf('.');
|
|
661
|
+
if (dot <= 0)
|
|
662
|
+
break;
|
|
663
|
+
current = current.slice(0, dot);
|
|
664
|
+
variants.push(`us${current}`);
|
|
665
|
+
}
|
|
666
|
+
return variants;
|
|
667
|
+
}
|
|
668
|
+
function normalizeOffshoreCodes(params, name, perCode) {
|
|
669
|
+
if (!isRecord(params))
|
|
670
|
+
throw new Error('params must be an object');
|
|
671
|
+
const rawValues = Array.isArray(params.codes) ? params.codes : typeof params.codes === 'string' ? params.codes.split(',').map((item) => item.trim()).filter(Boolean) : undefined;
|
|
672
|
+
if (!rawValues || rawValues.length === 0)
|
|
673
|
+
throw new Error(`parameter ${name} must contain at least one code`);
|
|
674
|
+
if (rawValues.length > 50)
|
|
675
|
+
throw new Error(`parameter ${name} accepts at most 50 codes`);
|
|
676
|
+
const symbols = [];
|
|
677
|
+
for (const item of rawValues) {
|
|
678
|
+
const symbol = perCode(item, name);
|
|
679
|
+
if (!symbols.includes(symbol))
|
|
680
|
+
symbols.push(symbol);
|
|
681
|
+
}
|
|
682
|
+
return { codes: symbols };
|
|
683
|
+
}
|
|
684
|
+
async function executeOffshoreQuote(params, signal, market) {
|
|
685
|
+
const codes = params.codes;
|
|
686
|
+
if (market === 'hk') {
|
|
687
|
+
const symbols = codes.map((code) => `hk${code}`);
|
|
688
|
+
const text = await getText(`${QUOTE_URL}${symbols.join(',')}`, signal, 'gbk');
|
|
689
|
+
const rows = parseHkQuoteRows(text, new Set(symbols.map((symbol) => symbol.toLowerCase())));
|
|
690
|
+
if (rows.length === 0)
|
|
691
|
+
throw sourceError(`Tencent returned no HK quote rows for ${symbols.join(',')}`, 'tencent_empty_response');
|
|
692
|
+
return { data: rows, schema: hkQuoteOutput };
|
|
693
|
+
}
|
|
694
|
+
// 一轮把所有首选写法一起问出去;只有**没回行**的那几只才用剥掉后缀的写法再问一轮,
|
|
695
|
+
// 所以正常路径(纯字母 ticker)一次请求都不多花。
|
|
696
|
+
const pending = codes.map((ticker) => usSymbolVariants(ticker));
|
|
697
|
+
const rows = [];
|
|
698
|
+
const tried = new Set();
|
|
699
|
+
for (let round = 0; round < 3 && pending.some((list) => list.length > 0); round += 1) {
|
|
700
|
+
const symbols = pending.map((list) => list.shift()).filter((symbol) => Boolean(symbol));
|
|
701
|
+
for (const symbol of symbols)
|
|
702
|
+
tried.add(symbol.toLowerCase());
|
|
703
|
+
const text = await getText(`${QUOTE_URL}${symbols.join(',')}`, signal, 'gbk');
|
|
704
|
+
const found = parseUsQuoteRows(text, new Set(symbols.map((symbol) => symbol.toLowerCase())));
|
|
705
|
+
const hit = new Set(found.map((item) => item.symbol));
|
|
706
|
+
rows.push(...found.map((item) => item.row));
|
|
707
|
+
for (let index = 0; index < symbols.length; index += 1) {
|
|
708
|
+
if (hit.has(symbols[index])) {
|
|
709
|
+
pending[index] = [];
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
// 快照没认这个写法:剥一段点号再看;剥无可剥就只能认定腾讯不认识这只(不编造身份)。
|
|
713
|
+
const next = pending[index].find((variant) => !tried.has(variant.toLowerCase()));
|
|
714
|
+
if (next !== undefined)
|
|
715
|
+
pending[index] = [next, ...pending[index].filter((variant) => variant !== next)];
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
if (rows.length === 0)
|
|
719
|
+
throw sourceError(`Tencent returned no US quote rows for ${codes.join(',')}`, 'tencent_empty_response');
|
|
720
|
+
return { data: rows, schema: usQuoteOutput };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* 美股 K 线的入口需要**带交易所后缀**的符号(实测 `usAAPL` 会返回一列 2011 年的陌生序列,
|
|
724
|
+
* `usAAPL.NS` 直接 0 行——都不报错)。所以先从快照取上游自己的 2 号位身份再打 K 线:
|
|
725
|
+
* 消歧发生在调用期、由上游回答,不靠模型记、也不靠猜(§9.7)。
|
|
726
|
+
*/
|
|
727
|
+
async function resolveUsKlineSymbol(ticker, signal) {
|
|
728
|
+
for (const symbol of usSymbolVariants(ticker)) {
|
|
729
|
+
const text = await getText(`${QUOTE_URL}${symbol}`, signal, 'gbk');
|
|
730
|
+
const [row] = snapshotRows(text, new Set([symbol.toLowerCase()]));
|
|
731
|
+
if (!row || row.fields.length < US_QUOTE_FIELDS)
|
|
732
|
+
continue;
|
|
733
|
+
const code = row.fields[2];
|
|
734
|
+
if (!/^[A-Z][A-Z.]{0,9}\.[A-Z]{1,3}$/.test(code))
|
|
735
|
+
throw sourceError(`Tencent returned an unreadable US code ${JSON.stringify(code)}`, 'tencent_invalid_response');
|
|
736
|
+
return `us${code}`;
|
|
737
|
+
}
|
|
738
|
+
throw new Error(`Tencent does not recognize US ticker ${ticker}; no spelling of it returned a snapshot to resolve an exchange`);
|
|
739
|
+
}
|
|
740
|
+
const offshoreKlinePaths = {
|
|
741
|
+
hk: { qfq: '/appstock/app/hkfqkline/get', hfq: '/appstock/app/hkfqkline/get', none: '/appstock/app/kline/kline' },
|
|
742
|
+
us: { qfq: '/appstock/app/usfqkline/get', none: '/appstock/app/kline/kline' },
|
|
743
|
+
};
|
|
744
|
+
/** 港美股 K 线入口:主机轮换全部走 `callKlineHosts`(一份实现),这里只补"序列在哪个 symbol 的哪个键下"。 */
|
|
745
|
+
async function callOffshoreKline(path, param, symbol, signal) {
|
|
746
|
+
const data = await callKlineHosts({
|
|
747
|
+
path,
|
|
748
|
+
param,
|
|
749
|
+
label: `${symbol} K-line`,
|
|
750
|
+
isEmpty: (node) => {
|
|
751
|
+
const candidate = isRecord(node) ? node[symbol] : undefined;
|
|
752
|
+
return !isRecord(candidate) || Object.keys(candidate).find((key) => Array.isArray(candidate[key])) === undefined;
|
|
753
|
+
},
|
|
754
|
+
isParamRejection: (payload) => /param/i.test(String(payload.msg ?? '')) || Number(payload.code) === 1 || Number(payload.code) === 11,
|
|
755
|
+
}, signal);
|
|
756
|
+
const node = data[symbol];
|
|
757
|
+
if (!isRecord(node))
|
|
758
|
+
throw sourceError(`Tencent K-line ${symbol} response has no symbol node`, 'tencent_invalid_response');
|
|
759
|
+
const key = Object.keys(node).find((candidate) => Array.isArray(node[candidate]));
|
|
760
|
+
if (key === undefined)
|
|
761
|
+
throw sourceError(`Tencent K-line ${symbol} response has no series array`, 'tencent_invalid_response');
|
|
762
|
+
return node[key];
|
|
763
|
+
}
|
|
764
|
+
/** K 线行:`[日期, 开, 收, 高, 低, 量, …]`——与 A 股同一套位序,第 6 位是上游附带的回购/分红文本。 */
|
|
765
|
+
function parseOffshoreKlineRows(items, canonical, tencentSymbol, period, adjust, market) {
|
|
766
|
+
const rows = [];
|
|
767
|
+
for (const item of items) {
|
|
768
|
+
if (!Array.isArray(item) || item.length < 6)
|
|
769
|
+
throw sourceError(`Tencent ${market} K-line ${tencentSymbol} row shape changed`, 'tencent_invalid_response');
|
|
770
|
+
const date = String(item[0]);
|
|
771
|
+
// 只匹配 `\d{4}-\d{2}-\d{2}` 会把 2026-13-99 这样的假日期放过去,所以要按真历法回读一次。
|
|
772
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || (() => { const parsed = new Date(`${date}T00:00:00Z`); return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date; })()) {
|
|
773
|
+
throw sourceError(`Tencent ${market} K-line ${tencentSymbol} has unreadable date ${JSON.stringify(date)}`, 'tencent_invalid_response');
|
|
774
|
+
}
|
|
775
|
+
const open = requiredNumber(item[1], 'open');
|
|
776
|
+
const close = requiredNumber(item[2], 'close');
|
|
777
|
+
const high = requiredNumber(item[3], 'high');
|
|
778
|
+
const low = requiredNumber(item[4], 'low');
|
|
779
|
+
const volume = requiredNumber(item[5], 'volume');
|
|
780
|
+
if (open <= 0 || close <= 0 || high <= 0 || low <= 0 || high < low)
|
|
781
|
+
throw sourceError(`Tencent ${market} K-line ${tencentSymbol} ${date} has an impossible price range`, 'tencent_invalid_response');
|
|
782
|
+
const row = {
|
|
783
|
+
code: canonical,
|
|
784
|
+
tencent_symbol: tencentSymbol,
|
|
785
|
+
adjust,
|
|
786
|
+
period,
|
|
787
|
+
date,
|
|
788
|
+
open,
|
|
789
|
+
high,
|
|
790
|
+
low,
|
|
791
|
+
close,
|
|
792
|
+
volume,
|
|
793
|
+
};
|
|
794
|
+
if (market === 'hk') {
|
|
795
|
+
// 港股复权序列多回两列(第 7/8 位);实测不复权序列只有七列,这两处就是 null。
|
|
796
|
+
row.turnover_pct = item.length > 7 ? numberOrNull(item[7]) : null;
|
|
797
|
+
row.amount_wan_hkd = item.length > 8 ? numberOrNull(item[8]) : null;
|
|
798
|
+
}
|
|
799
|
+
rows.push(row);
|
|
800
|
+
}
|
|
801
|
+
if (rows.length === 0)
|
|
802
|
+
throw sourceError(`Tencent ${market} K-line ${tencentSymbol} returned no rows`, 'tencent_empty_response');
|
|
803
|
+
return rows;
|
|
804
|
+
}
|
|
805
|
+
function normalizeOffshoreKline(params, market) {
|
|
806
|
+
if (!isRecord(params))
|
|
807
|
+
throw new Error('params must be an object');
|
|
808
|
+
// start/end 进 allowed 只为了给出**可用**的拒绝理由:默默忽略日期是最坏的一种失败(调用方以为过滤过了)。
|
|
809
|
+
assertKnown(params, ['code', 'period', 'adjust', 'count', 'start', 'end']);
|
|
810
|
+
if (params.start !== undefined || params.end !== undefined)
|
|
811
|
+
throw new Error('start/end are not supported: 实测 hkfqkline 忽略日期区间(带 2024-01-01..2024-03-01 与 count=640 仍回 640 条、最早到 2021-07-30),只有 count 生效');
|
|
812
|
+
const code = market === 'hk' ? normalizeHkCode(params.code, 'code') : normalizeUsCode(params.code, 'code');
|
|
813
|
+
const period = params.period === undefined ? 'day' : String(params.period).toLowerCase();
|
|
814
|
+
if (!['day', 'week', 'month'].includes(period))
|
|
815
|
+
throw new Error('parameter period must be day, week or month (腾讯港美股入口没有分钟线:实测 hk 的 mkline 连接失败、proxy 侧回 code -1)');
|
|
816
|
+
const adjust = params.adjust === undefined ? 'qfq' : String(params.adjust).toLowerCase();
|
|
817
|
+
if (!Object.prototype.hasOwnProperty.call(offshoreKlinePaths[market], adjust)) {
|
|
818
|
+
throw new Error(market === 'us'
|
|
819
|
+
? 'parameter adjust must be qfq or none for US K-lines (实测 usfqkline 把 hfq 请求映射成与不复权完全相同的价格序列,我们不承诺一个给不出后复权的入口)'
|
|
820
|
+
: `parameter adjust ${adjust} is not available for HK K-lines (实测 hk 端点空 adjust 回 bad params,只有 qfq/hfq 与不带复权的 kline/kline 两条)`);
|
|
821
|
+
}
|
|
822
|
+
const count = params.count === undefined ? 320 : integer(params.count, 'count', 1, 640);
|
|
823
|
+
return { code, period, adjust, count };
|
|
824
|
+
}
|
|
825
|
+
async function executeOffshoreKline(params, signal, market) {
|
|
826
|
+
const code = String(params.code);
|
|
827
|
+
const period = String(params.period);
|
|
828
|
+
const adjust = String(params.adjust);
|
|
829
|
+
const count = Number(params.count);
|
|
830
|
+
// K 线的 symbol 身份:港股就是 hk+5 位;美股必须带交易所后缀,且后缀由快照回答。
|
|
831
|
+
const tencentSymbol = market === 'hk' ? `hk${code}` : await resolveUsKlineSymbol(code, signal);
|
|
832
|
+
const canonical = market === 'hk' ? code : tencentSymbol.slice(2);
|
|
833
|
+
const path = offshoreKlinePaths[market][adjust];
|
|
834
|
+
const param = `${tencentSymbol},${period},,,${count},${adjust === 'none' ? '' : adjust}`;
|
|
835
|
+
const items = await callOffshoreKline(path, param, tencentSymbol, signal);
|
|
836
|
+
return { data: parseOffshoreKlineRows(items, canonical, tencentSymbol, period, adjust, market), schema: market === 'hk' ? hkKlineOutput : usKlineOutput };
|
|
837
|
+
}
|
|
838
|
+
const hkQuoteOutput = {
|
|
839
|
+
type: 'array',
|
|
840
|
+
items: {
|
|
841
|
+
type: 'object',
|
|
842
|
+
properties: {
|
|
843
|
+
code: { type: 'string' }, tencent_symbol: { type: 'string' }, name: { type: 'string' },
|
|
844
|
+
english_name: { type: 'string' }, currency: { type: 'string' },
|
|
845
|
+
price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
846
|
+
last_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
847
|
+
open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
848
|
+
high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
849
|
+
low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
850
|
+
change_amt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
851
|
+
change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
852
|
+
amplitude_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
853
|
+
volume_shares: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '成交量(股,不是手)' },
|
|
854
|
+
amount_hkd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '成交额(港元,元级)' },
|
|
855
|
+
avg_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
856
|
+
turnover_pct: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '换手率(百分数原值),分母是总股本' },
|
|
857
|
+
total_shares: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
858
|
+
market_cap_yi_hkd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '总市值(**亿港元**)= 总股本 × 现价 ÷ 1e8;上游没有流通口径' },
|
|
859
|
+
board_lot_shares: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '每手股数(实测腾讯 100、汇丰 400)' },
|
|
860
|
+
quote_time: { type: 'string' },
|
|
861
|
+
is_stale: { type: 'boolean' },
|
|
862
|
+
stale_reason: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
863
|
+
},
|
|
864
|
+
additionalProperties: true,
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
const usQuoteOutput = {
|
|
868
|
+
type: 'array',
|
|
869
|
+
items: {
|
|
870
|
+
type: 'object',
|
|
871
|
+
properties: {
|
|
872
|
+
code: { type: 'string' }, tencent_symbol: { type: 'string' }, exchange_code: { type: 'string' },
|
|
873
|
+
name: { type: 'string' }, english_name: { type: 'string' }, currency: { type: 'string' },
|
|
874
|
+
price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
875
|
+
last_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
876
|
+
open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
877
|
+
high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
878
|
+
low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
879
|
+
change_amt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
880
|
+
change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
881
|
+
amplitude_pct: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
882
|
+
volume_shares: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '成交量(股,不是手)' },
|
|
883
|
+
amount_usd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '成交额(美元,元级)' },
|
|
884
|
+
avg_price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
885
|
+
turnover_pct: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '换手率(百分数原值),分母是总股本(不是流通股本)' },
|
|
886
|
+
total_shares: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
887
|
+
float_shares: { oneOf: [{ type: 'number' }, { type: 'null' }] },
|
|
888
|
+
float_market_cap_yi_usd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '流通市值(**亿美元**)= 流通股本 × 现价 ÷ 1e8' },
|
|
889
|
+
market_cap_yi_usd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '总市值(**亿美元**)= 总股本 × 现价 ÷ 1e8' },
|
|
890
|
+
quote_time: { type: 'string' },
|
|
891
|
+
is_stale: { type: 'boolean' },
|
|
892
|
+
stale_reason: { oneOf: [{ type: 'string' }, { type: 'null' }] },
|
|
893
|
+
},
|
|
894
|
+
additionalProperties: true,
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
/** 港美股 K 线的公共列:`[日期, 开, 收, 高, 低, 量]`,量纲是**股**。 */
|
|
898
|
+
const offshoreKlineProperties = () => ({
|
|
899
|
+
code: { type: 'string' }, tencent_symbol: { type: 'string' }, adjust: { type: 'string' },
|
|
900
|
+
period: { type: 'string' }, date: { type: 'string' },
|
|
901
|
+
open: { type: 'number' }, high: { type: 'number' }, low: { type: 'number' }, close: { type: 'number' },
|
|
902
|
+
volume: { type: 'number', description: '成交量(股);不是 A 股 K 线那套"手"' },
|
|
903
|
+
});
|
|
904
|
+
// 只有**港股复权序列**多回两列,而其中成交额是**万港元**(实测腾讯 2026-10-05 给 366,702.882,
|
|
905
|
+
// 当日快照给 3,667,028,820.9 港元,差整整 1e4)。美股的两个可用入口(qfq / none)实测都只回六列,
|
|
906
|
+
// 所以美股这张表**没有** amount 字段——不声明一列恒为 null 的东西,那只会让人以为是缺数。
|
|
907
|
+
const hkKlineOutput = {
|
|
908
|
+
type: 'array',
|
|
909
|
+
items: {
|
|
910
|
+
type: 'object',
|
|
911
|
+
properties: {
|
|
912
|
+
...offshoreKlineProperties(),
|
|
913
|
+
turnover_pct: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '当日换手率(百分数原值,分母是总股本);不复权序列为 null' },
|
|
914
|
+
amount_wan_hkd: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '成交额(万港元,不是港元);不复权序列为 null' },
|
|
915
|
+
},
|
|
916
|
+
additionalProperties: true,
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
const usKlineOutput = { type: 'array', items: { type: 'object', properties: offshoreKlineProperties(), additionalProperties: true } };
|
|
920
|
+
function createSource(options) {
|
|
921
|
+
const schema = {
|
|
922
|
+
capability: options.capability,
|
|
923
|
+
time_contract: getDataTimeContract(options.capability),
|
|
924
|
+
name: options.name,
|
|
925
|
+
source: `http:tencent.${options.capability}`,
|
|
926
|
+
data_key: buildDataKey('tencent', 'http', options.capability),
|
|
927
|
+
source_label: 'tencent',
|
|
928
|
+
paginated: options.paginated === true,
|
|
929
|
+
cacheMaxAgeMs: options.cacheMaxAgeMs,
|
|
930
|
+
...(options.rowShape === undefined ? {} : { rowShape: options.rowShape }),
|
|
931
|
+
summary: options.summary,
|
|
932
|
+
description: options.description,
|
|
933
|
+
input_schema: options.inputSchema,
|
|
934
|
+
output_schema: options.outputSchema,
|
|
935
|
+
};
|
|
936
|
+
const normalize = (params) => {
|
|
937
|
+
if (!isRecord(params))
|
|
938
|
+
throw new Error('params must be an object');
|
|
939
|
+
assertKnown(params, options.allowed);
|
|
940
|
+
return options.normalize(params);
|
|
941
|
+
};
|
|
942
|
+
return {
|
|
943
|
+
schema,
|
|
944
|
+
normalizeParams: normalize,
|
|
945
|
+
validateOutput: (data) => {
|
|
946
|
+
if (options.capability === 'tencent_ticks')
|
|
947
|
+
return isRecord(data) && Array.isArray(data.rows);
|
|
948
|
+
return Array.isArray(data) && data.length > 0;
|
|
949
|
+
},
|
|
950
|
+
execute: async (request, signal) => options.execute(normalize(request.params), signal),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
export function createTencentSources() {
|
|
954
|
+
return [
|
|
955
|
+
createSource({
|
|
956
|
+
capability: 'tencent_quote',
|
|
957
|
+
cacheMaxAgeMs: 60_000,
|
|
958
|
+
name: 'get_tencent_quote',
|
|
959
|
+
summary: '腾讯实时行情、估值、涨跌停和 ETF 快照',
|
|
960
|
+
description: '批量获取 SH/SZ 股票、指数或 ETF 的腾讯实时快照。输入 codes 为完整证券代码数组(如 600519.SH、000001.SZ、000001.SH、510300.SH),返回保留腾讯字段语义的行数组:价格、涨跌、成交额(万元)、换手率、PE/PB、市值和涨跌停价。44 为流通市值、45 为总市值;43 为振幅,不是 PB。腾讯返回成交额为 0 且最新价等于昨收时标记 is_stale=true,不把它当作当日有效成交。BJ 代码当前明确拒绝。',
|
|
961
|
+
inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '完整证券代码,如 600519.SH' } } }, required: ['codes'], additionalProperties: false },
|
|
962
|
+
outputSchema: quoteOutput,
|
|
963
|
+
paginated: false,
|
|
964
|
+
rowShape: { rootArray: true },
|
|
965
|
+
allowed: ['codes'],
|
|
966
|
+
normalize: normalizeQuote,
|
|
967
|
+
execute: executeQuote,
|
|
968
|
+
}),
|
|
969
|
+
createSource({
|
|
970
|
+
capability: 'tencent_kline',
|
|
971
|
+
cacheMaxAgeMs: 60_000,
|
|
972
|
+
name: 'get_tencent_kline',
|
|
973
|
+
summary: '腾讯日周月复权和分钟 K 线',
|
|
974
|
+
description: '获取单只 SH/SZ 股票、指数或 ETF 的腾讯 K 线。period 为 day/week/month 或 m1/m5/m15/m30/m60;日周月可用 qfq/hfq/none,分钟线只能 none;start/end 仅日周月支持 YYYY-MM-DD,count 日周月最多 640、分钟最多 320。volume 单位为手,腾讯 K 线不提供成交额;分钟返回 turnover_rate_pct,腾讯原始字段为基点后除以 100。内部会按参考 skill 的三个腾讯入口轮换,但不把入口拆成 capability。BJ 代码明确拒绝。',
|
|
975
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: '单只完整证券代码,如 600519.SH' }, period: { type: 'string', enum: ['day', 'week', 'month', 'm1', 'm5', 'm15', 'm30', 'm60'] }, adjust: { type: 'string', enum: ['qfq', 'hfq', 'none'] }, start: { type: 'string', description: '日/周/月起始日期 YYYY-MM-DD' }, end: { type: 'string', description: '日/周/月结束日期 YYYY-MM-DD' }, count: { type: 'integer', minimum: 1, maximum: 640 } }, required: ['code'], additionalProperties: false },
|
|
976
|
+
outputSchema: klineOutput,
|
|
977
|
+
paginated: false,
|
|
978
|
+
rowShape: { rootArray: true },
|
|
979
|
+
allowed: ['code', 'period', 'adjust', 'start', 'end', 'count'],
|
|
980
|
+
normalize: normalizeKline,
|
|
981
|
+
execute: executeKline,
|
|
982
|
+
}),
|
|
983
|
+
createSource({
|
|
984
|
+
capability: 'tencent_ticks',
|
|
985
|
+
name: 'get_tencent_ticks',
|
|
986
|
+
summary: '腾讯最近交易日分笔成交明细',
|
|
987
|
+
description: '获取单只 SH/SZ 股票或 ETF 最近一个交易日的腾讯分笔成交。不是 Level-2 逐笔委托;约 3 秒一笔,volume 单位为手、amount 单位为元,side 为 B/S/M。只有最近交易日,不支持历史、BJ 或指数。收盘后会将连续竞价段成交额与腾讯行情快照核对;缺失的盘后序号写入 missing_seq,连续竞价缺号或成交额不一致会失败。',
|
|
988
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: '单只股票或 ETF 完整代码,如 000001.SZ 或 510300.SH' } }, required: ['code'], additionalProperties: false },
|
|
989
|
+
outputSchema: ticksOutput,
|
|
990
|
+
paginated: false,
|
|
991
|
+
rowShape: { rowKey: 'rows' },
|
|
992
|
+
allowed: ['code'],
|
|
993
|
+
normalize: normalizeTicks,
|
|
994
|
+
execute: executeTicks,
|
|
995
|
+
}),
|
|
996
|
+
createSource({
|
|
997
|
+
capability: 'tencent_hk_quote',
|
|
998
|
+
cacheMaxAgeMs: 60_000,
|
|
999
|
+
name: 'get_tencent_hk_quote',
|
|
1000
|
+
summary: '腾讯港股实时快照(港元,无涨跌停)',
|
|
1001
|
+
description: '批量获取香港个股的腾讯快照(一次最多 50 只)。代码是**五位数字**(00700,也接受 700 / hk00700 / 00700.HK,内部左补零——实测上游对不补零的 hk700、hk0700 直接不回行)。⛔ 不接受恒生系指数(HSI/HSTECH 一类字母代码):实测指数行的第 6/37 位与个股不同量纲(hkHSI 两列相等且是万元级,个股是"股"与"港元"),两种单位不许进同一列。currency 回 HKD;price / amount_hkd 是**港元(元级)**;market_cap_yi_hkd 是**亿港元**(实测等于 total_shares × price ÷ 1e8,腾讯 9,092,605,595 × 423 对 38,461.7217)。⛔ **港股没有涨跌停**,本表不提供 limit_up / limit_down,也不要拿 A 股那套规则推断。turnover_pct 的分母是**总股本**(实测 8,709,438 / 9,092,605,595 = 0.096% ≈ 给的 0.10),与美股口径不同。上游 44/45 两列实测恒等(都由总股本×现价得出),所以只给一个市值数,不许当成流通市值。avg_price = amount_hkd ÷ volume_shares(实测逐位相符)。board_lot_shares 是每手股数(实测腾讯 100、汇丰 400)。quote_time 是**交易所当地时间(HKT)**,不是北京时间。快照的 52 周高低两列与日线序列对不上(实测腾讯给 677.2/411 而近 252 个交易日是 683/411;汇丰给 168.916/94.532 而实际 169.7/100.4),窗口口径推不出来,**故不收录**——要区间高低请用 tencent_hk_kline 自己算;PE / PB / 股息率一类的其余列同样因口径未核验而不收录。不存在的代码(实测 hk99999)会被上游静默丢弃:返回行数少于请求行数就是那几个代码不存在或已退市,不是取数失败。成交额为 0 且现价等于昨收时标 is_stale=true(半日市与休市日会命中),不当作当日有效成交。',
|
|
1002
|
+
inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '港股代码,如 00700 或 hk00700' } } }, required: ['codes'], additionalProperties: false },
|
|
1003
|
+
outputSchema: hkQuoteOutput,
|
|
1004
|
+
paginated: false,
|
|
1005
|
+
rowShape: { rootArray: true },
|
|
1006
|
+
allowed: ['codes'],
|
|
1007
|
+
normalize: (params) => normalizeOffshoreCodes(params, 'codes', normalizeHkCode),
|
|
1008
|
+
execute: (params, signal) => executeOffshoreQuote(params, signal, 'hk'),
|
|
1009
|
+
}),
|
|
1010
|
+
createSource({
|
|
1011
|
+
capability: 'tencent_us_quote',
|
|
1012
|
+
cacheMaxAgeMs: 60_000,
|
|
1013
|
+
name: 'get_tencent_us_quote',
|
|
1014
|
+
summary: '腾讯美股快照(美元,交易所当地时间)',
|
|
1015
|
+
description: '批量获取美股与在美上市 ADR 的腾讯快照(一次最多 50 只)。代码是字母 ticker(BRK.B 这类带点的级别码可用)。⛔ 写法上有两点是实测出来的:腾讯**快照只认不带交易所后缀的形式**(`usBABA.N` 整行不回、`usBABA` 回),所以两位以上的尾段(.OQ/.AM/.NS)在参数层直接剥掉;**单字母尾段分不清是级别码还是交易所码**(BRK.B 与 BABA.N 都是真实写法),就照原样先问一次、没回行再剥一段重问——猜错只多一轮请求,猜错第二次才是把数据挂到别的标的上。currency 回 USD,price / amount_usd 是**美元(元级)**,float_market_cap_yi_usd 与 market_cap_yi_usd 是**亿美元**(实测两列分别等于流通股本 / 总股本 × 现价 ÷ 1e8:苹果 14,585,108,878 → 48,669.05、14,594,180,000 → 48,699.32)。code 回上游给的带后缀身份(AAPL.OQ / BABA.N),exchange_code 单列后缀(实测只出现 OQ 与 N,是腾讯自有交易所编码,本表不翻译)。⛔ **这不是实时撮合价**:实测 2026-10-05(周一)北京清早取到的最新成交时间是 2026-10-02 16:00:01,收盘后不给新数;腾讯在 K 线响应内嵌的行情节点 0 号位标 `delay`。quote_time 是**交易所当地时间(美东)**,与北京时间差 12/13 小时,跨市场同日比较前先对齐。turnover_pct 的分母是**总股本**(实测 GOOGL 给 0.19 = 23,772,148 ÷ 12,229,934,831,BRK.B 给 0.20 = 4,193,461 ÷ 2,140,709,794;按流通股本算是 0.41 与 0.34,对不上——多级股票类别下两个分母能差一倍,苹果与阿里因为两者接近所以看不出区别)。avg_price = amount_usd ÷ volume_shares(实测 11,087,245,013 / 33,278,552 = 333.16 相符)。美股无涨跌停,不提供相关列。52 周高低两列口径与日线序列对不上(实测苹果 345.34/242.76,近 252 个交易日是 345.34/243.42),**不收录**;PE/PB 一类列因口径未核验同样不收录。盘前盘后行情本表不透出(实测只出现在 K 线响应的 pandata 节点)。不认识的 ticker 被上游静默丢弃,行数少于请求数即那几个代码无效。成交额为 0 且现价等于昨收时标 is_stale=true。',
|
|
1016
|
+
inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '美股 ticker,如 AAPL 或 BRK.B' } } }, required: ['codes'], additionalProperties: false },
|
|
1017
|
+
outputSchema: usQuoteOutput,
|
|
1018
|
+
paginated: false,
|
|
1019
|
+
rowShape: { rootArray: true },
|
|
1020
|
+
allowed: ['codes'],
|
|
1021
|
+
normalize: (params) => normalizeOffshoreCodes(params, 'codes', normalizeUsCode),
|
|
1022
|
+
execute: (params, signal) => executeOffshoreQuote(params, signal, 'us'),
|
|
1023
|
+
}),
|
|
1024
|
+
createSource({
|
|
1025
|
+
capability: 'tencent_hk_kline',
|
|
1026
|
+
cacheMaxAgeMs: 60_000,
|
|
1027
|
+
name: 'get_tencent_hk_kline',
|
|
1028
|
+
summary: '腾讯港股日周月 K 线(成交量以股计)',
|
|
1029
|
+
description: '获取单只港股的腾讯日线/周线/月线。⛔ **没有分钟线**(实测 hk 走 mkline 连接失败、走 proxy 主机回 code -1),要港美股分时本能力覆盖不了。⛔ **不支持日期区间**:实测带 2024-01-01..2024-03-01 的 param 被上游忽略,返回仍是最近 640 条(最早到 2021-07-30),所以这里只有 count(1..640,默认 320),传 start/end 一律拒绝而不是静默忽略。adjust 默认 qfq,可用 qfq / hfq / none——none 不是"空着传",实测 hkfqkline 空 adjust 回 bad params,不复权走另一个入口。volume 单位是**股**(实测腾讯 2026-10-05 给 8,709,438,与当日快照成交量逐位相同),不是 A 股 K 线那套"手"。turnover_pct 是当日换手率(实测成交量 ÷ 总股本相符);amount_wan_hkd 是**万港元**(实测 366,702.882 万对快照 3,667,028,820.9 元,差整整 1e4);不复权序列不给这两列(为 null)。行按上游顺序返回,日期畸形、价格非正或 high < low 一律 tencent_invalid_response,不补 0。指数代码不接受(与快照同一口径)。',
|
|
1030
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: '港股代码,如 00700' }, period: { type: 'string', enum: ['day', 'week', 'month'] }, adjust: { type: 'string', enum: ['qfq', 'hfq', 'none'] }, count: { type: 'integer', minimum: 1, maximum: 640 } }, required: ['code'], additionalProperties: false },
|
|
1031
|
+
outputSchema: hkKlineOutput,
|
|
1032
|
+
paginated: false,
|
|
1033
|
+
rowShape: { rootArray: true },
|
|
1034
|
+
allowed: ['code', 'period', 'adjust', 'count', 'start', 'end'],
|
|
1035
|
+
normalize: (params) => normalizeOffshoreKline(params, 'hk'),
|
|
1036
|
+
execute: (params, signal) => executeOffshoreKline(params, signal, 'hk'),
|
|
1037
|
+
}),
|
|
1038
|
+
createSource({
|
|
1039
|
+
capability: 'tencent_us_kline',
|
|
1040
|
+
cacheMaxAgeMs: 60_000,
|
|
1041
|
+
name: 'get_tencent_us_kline',
|
|
1042
|
+
summary: '腾讯美股日周月 K 线(前复权或不复权)',
|
|
1043
|
+
description: '获取单只美股(含 ADR)的腾讯日线/周线/月线。⛔ 没有分钟线,不接受指数;⛔ **不支持日期区间**(同港股:只有 count 1..640,默认 320,传 start/end 直接拒绝)。adjust 只接受 **qfq 与 none**:实测 hfq 请求会被上游映射成与不复权**完全相同**的价格序列(苹果 2025-06-26 hfq 给 201.43/201.00,与不复权逐位相同,而 qfq 给 200.46/200.04),所以本能力不提供 hfq——宁可不给,也不给一个名字骗人的序列。⛔ **美股 K 线只有 OHLCV**:实测 qfq 与 none 两个入口都只回六列,成交额与换手率在这里拿不到(多出来的列只出现在被本能力拒绝的 hfq 形态里,而 hfq 回的其实是不复权价格)——要美股成交额请走别的来源,行里也没有 amount / turnover 字段,不要当成缺数。K 线入口需要带交易所后缀的符号:实测裸 `usAAPL` 会回一列 2011 年起的陌生序列、`usAAPL.NS` 静默回 0 行,所以代码先由快照解析出真实后缀(AAPL.OQ)再打 K 线,每行的 tencent_symbol 原样回显解析结果供核对;ticker 认不出来时直接点名"腾讯不认识这个代码"。volume 单位是**股**;日期畸形、价格非正或 high < low 一律响亮失败。',
|
|
1044
|
+
inputSchema: { type: 'object', properties: { code: { type: 'string', description: '美股 ticker,如 AAPL 或 BRK.B' }, period: { type: 'string', enum: ['day', 'week', 'month'] }, adjust: { type: 'string', enum: ['qfq', 'none'] }, count: { type: 'integer', minimum: 1, maximum: 640 } }, required: ['code'], additionalProperties: false },
|
|
1045
|
+
outputSchema: usKlineOutput,
|
|
1046
|
+
paginated: false,
|
|
1047
|
+
rowShape: { rootArray: true },
|
|
1048
|
+
allowed: ['code', 'period', 'adjust', 'count', 'start', 'end'],
|
|
1049
|
+
normalize: (params) => normalizeOffshoreKline(params, 'us'),
|
|
1050
|
+
execute: (params, signal) => executeOffshoreKline(params, signal, 'us'),
|
|
1051
|
+
}),
|
|
1052
|
+
];
|
|
1053
|
+
}
|