@v587d/capital-generation 2.6.2 → 2.6.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-watchlist",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "Capital 模式的用户自选股(宿主级清单 + Fuyao 报价快照 + /capital-watchlist 路由 + 输入框菜单弹窗):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v587d/capital-charts",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "Capital 模式的图表前端组件(浏览器半边 + 序列旁路路由):由 @v587d/capital-generation 随包携带并作为 host 平面行挂载。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,8 +1,9 @@
1
1
  import { buildDataKey } from '../data-collector/hub.js';
2
2
  import { getDataTimeContract } from '../time/tools.js';
3
- import { createRequestThrottle } from '../net/throttle.js';
4
3
  import { isLikelyIndex, normalizeSecurityCodes, parseSecurityCode, requireTencentSecurity } from './security-code.js';
5
- const QUOTE_URL = 'https://qt.gtimg.cn/q=';
4
+ // 港美股快照、smartbox 与那条**唯一的**出口节流链都在内核里;本文件只留能力定义与 K 线 / 分笔编排。
5
+ // 为什么必须共用:两条独立节流等于没有限流,两份字段位序表迟早只会修好一份(AGENTS.md §9.7)。
6
+ import { QUOTE_URL, fetchOffshoreIndexQuotes, fetchOffshoreQuotes, isRecord, normalizeHkCode, normalizeOffshoreIndexCode, normalizeUsCode, numberOrNull, requiredNumber, resolveUsKlineSymbol, sourceError, tencentGetText as getText, } from './tencent-public-core.js';
6
7
  const TICK_URL = 'https://stock.gtimg.cn/data/index.php';
7
8
  const KLINE_HOSTS = [
8
9
  'https://web.ifzq.gtimg.cn',
@@ -16,15 +17,6 @@ const KLINE_SPAN_DAYS = { day: 700, week: 3650, month: 18250 };
16
17
  const MAX_TICK_PAGES = 300;
17
18
  const TICK_SESSION_END = '15:00:59';
18
19
  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
20
  const quoteOutput = {
29
21
  type: 'array',
30
22
  items: {
@@ -104,28 +96,11 @@ const ticksOutput = {
104
96
  required: ['date', 'code', 'tencent_symbol', 'rows', 'missing_seq'],
105
97
  additionalProperties: true,
106
98
  };
107
- function isRecord(value) {
108
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
109
- }
110
99
  function assertKnown(values, allowed) {
111
100
  for (const key of Object.keys(values))
112
101
  if (!allowed.includes(key))
113
102
  throw new Error(`unsupported parameter: ${key}`);
114
103
  }
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
104
  function strictDate(value, name) {
130
105
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value))
131
106
  throw new Error(`parameter ${name} must be YYYY-MM-DD`);
@@ -139,30 +114,6 @@ function integer(value, name, min, max) {
139
114
  throw new Error(`parameter ${name} must be an integer from ${min} to ${max}`);
140
115
  return value;
141
116
  }
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
117
  function parseJson(text, context) {
167
118
  if (!text.trim())
168
119
  throw sourceError(`Tencent ${context} returned an empty response`, 'tencent_empty_response');
@@ -516,155 +467,9 @@ async function executeTicks(params, signal) {
516
467
  * 港美股快照与 A 股快照**不能共用一份字段表**:实测腾讯在同一个 `~` 分隔串里换了列位——
517
468
  * 币种 A 股根本没有、港股在第 75 位、美股在第 35 位;换手率港股在第 59 位(分母是**总股本**)、
518
469
  * 美股在第 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
- * 从字面上分不出来——所以不猜。
470
+ * 所以两张表各自映射(都在 `tencent-public-core.ts`,host 半边与这里共用),
471
+ * 并且只收能用算术或量级证明的列(§10.2:口径猜错就是话说错)。
644
472
  */
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
473
  function normalizeOffshoreCodes(params, name, perCode) {
669
474
  if (!isRecord(params))
670
475
  throw new Error('params must be an object');
@@ -682,60 +487,14 @@ function normalizeOffshoreCodes(params, name, perCode) {
682
487
  return { codes: symbols };
683
488
  }
684
489
  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 };
490
+ // 取数编排(含美股"没回行就剥一段后缀重问")在内核里,与自选股面板共用一份。
491
+ const rows = await fetchOffshoreQuotes(market, params.codes, signal);
492
+ return { data: rows, schema: market === 'hk' ? hkQuoteOutput : usQuoteOutput };
721
493
  }
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`);
494
+ async function executeOffshoreIndexQuote(params, signal, market) {
495
+ // 指数只收点位那一族可证明的列;写法来自检索,身份形状由内核核对(猜写法会静默拿到别的标的)。
496
+ const rows = await fetchOffshoreIndexQuotes(market, params.codes, signal);
497
+ return { data: rows, schema: market === 'hk' ? hkIndexQuoteOutput : usIndexQuoteOutput };
739
498
  }
740
499
  const offshoreKlinePaths = {
741
500
  hk: { qfq: '/appstock/app/hkfqkline/get', hfq: '/appstock/app/hkfqkline/get', none: '/appstock/app/kline/kline' },
@@ -894,6 +653,37 @@ const usQuoteOutput = {
894
653
  additionalProperties: true,
895
654
  },
896
655
  };
656
+ /**
657
+ * 港美股**指数**快照的输出表:只有点位那一族,且**刻意不收**任何量纲列与币种列。
658
+ *
659
+ * 为什么不收(2026-10-07 真报文):港股指数行的第 6 / 36 / 37 位两列相等且量级像万元,
660
+ * 美股指数行的第 37 位给了 2.1e14、第 38 / 44 / 45 / 62 / 63 位为空——口径推不出来,
661
+ * 声明一列我们没有把握的东西比少给一列更糟(docs/dev/tool-schema.md §10.2)。
662
+ * 为什么不收币种:上游确实在第 75 / 35 位塞了 `HKD` / `USD`,但指数是无量纲的数。
663
+ */
664
+ const offshoreIndexQuoteProperties = (symbolLabel) => ({
665
+ code: { type: 'string' },
666
+ tencent_symbol: { type: 'string' },
667
+ name: { type: 'string' },
668
+ english_name: { type: 'string' },
669
+ price: { oneOf: [{ type: 'number' }, { type: 'null' }] },
670
+ last_close: { oneOf: [{ type: 'number' }, { type: 'null' }] },
671
+ open: { oneOf: [{ type: 'number' }, { type: 'null' }] },
672
+ high: { oneOf: [{ type: 'number' }, { type: 'null' }] },
673
+ low: { oneOf: [{ type: 'number' }, { type: 'null' }] },
674
+ change_amt: { oneOf: [{ type: 'number' }, { type: 'null' }] },
675
+ change_pct: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '涨跌百分比原值(点位变化 ÷ 前收,与个股同一口径)' },
676
+ amplitude_pct: { oneOf: [{ type: 'number' }, { type: 'null' }], description: '振幅(百分数原值)=(最高 − 最低)÷ 前收' },
677
+ quote_time: { type: 'string', description: `${symbolLabel}当地时间,不是北京时间` },
678
+ });
679
+ const hkIndexQuoteOutput = {
680
+ type: 'array',
681
+ items: { type: 'object', properties: offshoreIndexQuoteProperties('香港'), additionalProperties: true },
682
+ };
683
+ const usIndexQuoteOutput = {
684
+ type: 'array',
685
+ items: { type: 'object', properties: offshoreIndexQuoteProperties('美东'), additionalProperties: true },
686
+ };
897
687
  /** 港美股 K 线的公共列:`[日期, 开, 收, 高, 低, 量]`,量纲是**股**。 */
898
688
  const offshoreKlineProperties = () => ({
899
689
  code: { type: 'string' }, tencent_symbol: { type: 'string' }, adjust: { type: 'string' },
@@ -1021,6 +811,34 @@ export function createTencentSources() {
1021
811
  normalize: (params) => normalizeOffshoreCodes(params, 'codes', normalizeUsCode),
1022
812
  execute: (params, signal) => executeOffshoreQuote(params, signal, 'us'),
1023
813
  }),
814
+ createSource({
815
+ capability: 'tencent_hk_index_quote',
816
+ cacheMaxAgeMs: 60_000,
817
+ name: 'get_tencent_hk_index_quote',
818
+ summary: '腾讯港股指数实时点位(无量纲,不含成交与市值)',
819
+ description: '批量获取港股**指数**的腾讯快照点位(一次最多 50 条)。代码是字母指数码,实测可用:HSI 恒生指数、HSTECH 恒生科技指数、HSCEI 国企指数、VHSI 恒指波动率指数(实测 24151.9 / 4184.74 / 8075.59 / 18.13,盘中逐秒在动)。⛔ **写法只能来自检索**(ticker_search 或自选股那一路),不许凭猜:猜错时上游可能静默回别的标的且不报错。⛔ 与 tencent_hk_quote 互斥——五位数字代码属于个股那一路,这里一律拒绝;个股表里也不接受字母指数,因为实测指数行的第 6/36/37 位两列相等且量级像万元,与个股的"股 / 港元"不是同一量纲。本表因此**只声明点位那一族可证明的列**:price / last_close / open / high / low / change_amt / change_pct / amplitude_pct / quote_time;成交量、成交额、换手率、市值**不收录**(口径推不出来,声明一列没把握的东西比少给一列更糟)。**也没有 currency 列**:上游在第 75 位确实给了 HKD,但指数点位是无量纲的数,给它标货币单位是替上游说话。quote_time 是**交易所当地时间(HKT)**。不标 is_stale——个股那条判据(成交额 0 且现价等于昨收)在指数行上不可用;要判断市场此刻开不开盘请看行情快照内嵌的市场状态或对照 tencent_*_quote 的个股行。不存在的代码被上游静默丢弃(实测 hkZVHSI 不回行),行数少于请求数即那几个代码无效,不是取数失败。',
820
+ inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '港股指数代码,如 HSI 或 HSTECH' } } }, required: ['codes'], additionalProperties: false },
821
+ outputSchema: hkIndexQuoteOutput,
822
+ paginated: false,
823
+ rowShape: { rootArray: true },
824
+ allowed: ['codes'],
825
+ normalize: (params) => normalizeOffshoreCodes(params, 'codes', (value, name) => normalizeOffshoreIndexCode(value, name, 'hk')),
826
+ execute: (params, signal) => executeOffshoreIndexQuote(params, signal, 'hk'),
827
+ }),
828
+ createSource({
829
+ capability: 'tencent_us_index_quote',
830
+ cacheMaxAgeMs: 60_000,
831
+ name: 'get_tencent_us_index_quote',
832
+ summary: '腾讯美股指数点位(无量纲,收盘值为真值)',
833
+ description: '批量获取美股**指数**的腾讯快照点位(一次最多 50 条)。请求写法是**不带前导点的字母码**,实测可用三只:IXIC 纳斯达克(回 .IXIC 27599.89)、DJI 道琼斯(回 .DJI 51521.28)、INX 标普500(回 .INX 7818.93);返回的 code 已剥掉那个前导点(IXIC / DJI / INX),tencent_symbol 原样回显上游写法供核对。⛔ **标普500 在这里的写法是 INX,不是 SPX**:实测 usSPX / usNDQ / usSOX / usRUT 都不回行,而 usSSPX 会静默回一只 ETF(SSPX.AM,价 31.47)——所以写法必须来自检索,猜写法不仅可能拿不到数,还可能拿到**别人的**数且不报错;本能力靠回显形状(点号在开头)把 ETF 那一类挡在行外。⛔ 与 tencent_us_quote 互斥:AAPL / AAPL.OQ 这类个股写法(点号在中间)在这里一律拒绝。本表只声明点位那一族可证明的列,成交量 / 成交额 / 换手率 / 市值不收录(实测指数行第 37 位给了 2.1e14、第 38/44/45/62/63 位为空);**也没有 currency 列**(上游第 35 位给了 USD,但点位无量纲)。⛔ **同批指数的 quote_time 彼此不同**:实测同一份响应里 IXIC 是 18:34:12、DJI 是 16:42:37、INX 是 16:40:07——指数的收盘值在各成分结算后才定,晚的时刻不是"更新的数据"。quote_time 是**交易所当地时间(美东)**,与北京时间差 12/13 小时,跨市场同日比较前先对齐。不标 is_stale(判据依赖的成交列在本表不可用)。不认识的代码被上游静默丢弃,行数少于请求数即那几个代码无效。',
834
+ inputSchema: { type: 'object', properties: { codes: { type: 'array', minItems: 1, maxItems: 50, items: { type: 'string', description: '美股指数代码,如 IXIC、DJI、INX(不带前导点)' } } }, required: ['codes'], additionalProperties: false },
835
+ outputSchema: usIndexQuoteOutput,
836
+ paginated: false,
837
+ rowShape: { rootArray: true },
838
+ allowed: ['codes'],
839
+ normalize: (params) => normalizeOffshoreCodes(params, 'codes', (value, name) => normalizeOffshoreIndexCode(value, name, 'us')),
840
+ execute: (params, signal) => executeOffshoreIndexQuote(params, signal, 'us'),
841
+ }),
1024
842
  createSource({
1025
843
  capability: 'tencent_hk_kline',
1026
844
  cacheMaxAgeMs: 60_000,
@@ -0,0 +1,100 @@
1
+ export declare const QUOTE_URL = "https://qt.gtimg.cn/q=";
2
+ export declare const SMARTBOX_URL = "https://smartbox.gtimg.cn/s3/";
3
+ /**
4
+ * 腾讯出口的**唯一**节流链(进程级)。行情快照、分笔、K 线每一次换机、港美股快照、smartbox
5
+ * 检索全部过这一条链——它们打的是同一家,按 IP 风控(429 / 空 data / 静默限流)。
6
+ * 间隔取 120ms:与最早那版分笔翻页的节奏同量级。
7
+ */
8
+ export declare const TENCENT_MIN_INTERVAL_MS = 120;
9
+ export declare const tencentThrottle: <T>(task: () => Promise<T>) => Promise<T>;
10
+ export type JsonRecord = Record<string, unknown>;
11
+ export declare function isRecord(value: unknown): value is JsonRecord;
12
+ export declare function numberOrNull(value: unknown): number | null;
13
+ export declare function requiredNumber(value: unknown, field: string): number;
14
+ export declare function sourceError(message: string, code?: string): Error;
15
+ /**
16
+ * 腾讯出口的取文本:取消不排队(队首那个请求挂住时,把这次取消塞进队列等于把它吞掉,
17
+ * `docs/dev/web-retriever.md` §4.2「取消原样抛出」),排队与最小间隔由节流链负责。
18
+ */
19
+ export declare function tencentGetText(url: string, signal: AbortSignal, encoding?: 'utf-8' | 'gbk', init?: RequestInit): Promise<string>;
20
+ /**
21
+ * 港美股快照与 A 股快照**不能共用一份字段表**:实测腾讯在同一个 `~` 分隔串里换了列位——
22
+ * 币种 A 股根本没有、港股在第 75 位、美股在第 35 位;换手率港股在第 59 位(分母是**总股本**)、
23
+ * 美股在第 38 位(分母是**流通股本**);成交额在港股**指数行**是万元级而个股行是元级。
24
+ * 所以这两张表各自映射,并且只收能用算术或量级证明的列(docs/dev/tool-schema.md §10.2:口径猜错就是话说错)。
25
+ */
26
+ export declare const HK_QUOTE_FIELDS = 78;
27
+ export declare const US_QUOTE_FIELDS = 73;
28
+ /**
29
+ * 腾讯港美股时间戳是**交易所当地时间**。分隔符**按行漂移**:2026-10-05 那批同一份响应里,
30
+ * 一行给 `YYYY/MM/DD HH:mm:ss`、另一行给 `YYYY-MM-DD HH:mm:ss`——所以两种都吃,输出统一成横杠那一种。
31
+ * (写成占位符而不是真日期:这两个形状在源码里只差一个字符,肉眼读注释分不出来。)
32
+ */
33
+ export declare function offshoreQuoteTime(value: unknown): string | null;
34
+ /** 逐行拆出 `v_<symbol>="…"`;symbol 就是请求里写出去的那串,所以请求与回执能一对一核对。 */
35
+ export declare function snapshotRows(text: string, requested: Set<string>): Array<{
36
+ symbol: string;
37
+ fields: string[];
38
+ }>;
39
+ /** 港美股代码:HK 是 5 位数字(实测 `hk700`/`hk0700` 被上游静默丢掉,必须左补零),指数别名一律拒收。 */
40
+ export declare function normalizeHkCode(value: unknown, name: string): string;
41
+ /**
42
+ * 美股写法照原样收下(`AAPL` / `BRK.B` / `BABA.N` / `AAPL.OQ` 都是真实存在的说法),
43
+ * 消歧交给上游(见 `usSymbolVariants`):实测腾讯**快照只认不带后缀的写法**(`usBABA.N` 整行不回,
44
+ * `usBABA` 认),而 K 线只认**带正确后缀**的写法(`usAAPL` 回一列 2011 年的陌生序列、
45
+ * `usAAPL.NS` 静默 0 行)。点号既可能是级别码(BRK.B / BF.B)也可能是交易所后缀(.N / .OQ),
46
+ * 从字面上分不出来——所以不猜。
47
+ */
48
+ export declare function normalizeUsCode(value: unknown, name: string): string;
49
+ /** 依次尝试的写法:原样 → 每次剥掉最后一段点号,最多剥两段(BRK.B.N → BRK.B → BRK)。 */
50
+ export declare function usSymbolVariants(ticker: string): string[];
51
+ export declare function parseHkQuoteRows(text: string, requested: Set<string>): JsonRecord[];
52
+ export declare function parseUsQuoteRows(text: string, requested: Set<string>): Array<{
53
+ symbol: string;
54
+ row: JsonRecord;
55
+ }>;
56
+ /**
57
+ * **港美股指数快照**:只收点位那一族能证明的列。
58
+ *
59
+ * 为什么必须另开一张表(而不是复用个股那张):实测指数行的量纲列不可读——
60
+ * 港 `hkHSI` 的第 6 / 36 / 37 位两列相等且量级像万元,美 `usIXIC` 的第 37 位给了 2.1e14、
61
+ * 第 38 / 44 / 45 / 62 / 63 位是空。把这些列收进输出表就等于承诺一个我们证明不了的口径。
62
+ *
63
+ * 也**不收币种**:上游确实在第 75 / 35 位塞了 `HKD` / `USD`,但点位是无量纲的数,
64
+ * 替它标货币单位是替上游说话(`docs/design/watchlist-hk-us.md` §3.5)。
65
+ *
66
+ * 身份守卫是形状而非猜测:指数行第 2 位美股回 `.IXIC`(**前导点**)、港股回 `HSI`(**纯字母**);
67
+ * 个股行美股回 `AAPL.OQ`(点在中间)、港股回 `00700`(5 位数字)。2026-10-07 实测踩到过
68
+ * `usSSPX` 回的是 `SSPX.AM`(一只 ETF)——所以猜写法拿指数必错,而这一层的形状核对是最后一道闸。
69
+ */
70
+ export declare function parseOffshoreIndexRows(text: string, requested: Set<string>, market: 'hk' | 'us'): JsonRecord[];
71
+ /** 港美股指数代码:HK 是字母码(HSI / HSTECH / HSCEI),美是裸字母码(IXIC / DJI / INX,请求时不带那个前导点)。 */
72
+ export declare function normalizeOffshoreIndexCode(value: unknown, name: string, market: 'hk' | 'us'): string;
73
+ /**
74
+ * 港美股快照的**唯一**取数编排(能力侧与自选股面板共用)。
75
+ * 港股一次批量问完;美股先按原写法问一轮,**没回行**的那几只才用剥掉后缀的写法再问,
76
+ * 所以正常路径(纯字母 ticker)一次请求都不多花。
77
+ */
78
+ export declare function fetchOffshoreQuotes(market: 'hk' | 'us', codes: string[], signal: AbortSignal): Promise<JsonRecord[]>;
79
+ /** 港美股指数快照的取数编排:字母码没有"剥后缀重问"这一说,所以一轮问完。 */
80
+ export declare function fetchOffshoreIndexQuotes(market: 'hk' | 'us', codes: string[], signal: AbortSignal): Promise<JsonRecord[]>;
81
+ /** 美股 K 线入口需要的带后缀身份:先从快照拿上游自己的第 2 位(能力侧用,形状判据同一条)。 */
82
+ export declare function resolveUsKlineSymbol(ticker: string, signal: AbortSignal): Promise<string>;
83
+ /**
84
+ * smartbox(腾讯公开检索)的一行候选。
85
+ *
86
+ * 回包形状是 `v_hint="市场~代码~名称~拼音~类型^…"`:ASCII 的 `\uXXXX` 转义(**不需要 GBK**)、
87
+ * `^` 分行、`~` 分列,最多约 10 条。无命中与**非法 `t` 值**都回 `N` 这一个哨兵(实测
88
+ * `t=hk,us` 这种列表形态也落进 `N`),所以解析器把它当零条、调用方把 `t` 钉成常量。
89
+ */
90
+ export interface SmartboxCandidate {
91
+ market: string;
92
+ code: string;
93
+ name: string;
94
+ pinyin: string;
95
+ type: string;
96
+ }
97
+ export declare function parseSmartbox(text: string): SmartboxCandidate[];
98
+ /** smartbox 检索:市场段只能是上游认的单值(`hk` / `us` / `all`),列表形态实测失效。 */
99
+ export declare const SMARTBOX_MARKETS: string[];
100
+ export declare function fetchSmartbox(query: string, market: string, signal: AbortSignal): Promise<SmartboxCandidate[]>;