@v587d/capital-generation 2.6.2 → 2.6.3
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 +43 -6
- package/README.md +4 -4
- package/capital-config/package.json +1 -1
- package/capital-watchlist/client.js +14 -8
- package/capital-watchlist/client.src.cjs +160 -39
- package/capital-watchlist/index.js +479 -65
- package/capital-watchlist/package.json +1 -1
- package/chart-ui/package.json +1 -1
- package/lib/sources/tencent-http.js +71 -253
- package/lib/sources/tencent-public-core.d.ts +100 -0
- package/lib/sources/tencent-public-core.js +386 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +1 -1
|
@@ -9,15 +9,26 @@
|
|
|
9
9
|
* 三条纪律在这里落地:
|
|
10
10
|
* - ⛔ 认证围栏与 `/capital-charts` 同一写法(`connection.requestRejection`)——
|
|
11
11
|
* 不接就是"任何本机进程凭路径即可读写用户的自选股"。
|
|
12
|
-
* - 出网复用 `lib/sources/fuyao-core.js
|
|
13
|
-
*
|
|
12
|
+
* - 出网复用 `lib/sources/fuyao-core.js` 与 `lib/sources/tencent-public-core.js`(**同一份实现**,
|
|
13
|
+
* AGENTS.md §9.7);key 引用名从 `capital-config` 卡片读,不硬编码 `FUYAO_API_KEY`。
|
|
14
|
+
* 两路上游按**行类**分工:A 股族走 Fuyao(要密钥),港美股走腾讯公开端点(不要密钥)——
|
|
15
|
+
* 所以一路上游失败只降它自己那一路,`credential_missing` 的射程从"整块面板"缩到"A 股那一路"。
|
|
14
16
|
* - 报价按 `asset_type` 分流;ETF 上游只接受单只(实测 `code=1002`),所以逐只扇出,规模由清单
|
|
15
|
-
* 上限(30)钉住、由 `REFRESH_CONCURRENCY` 收敛成约 8
|
|
17
|
+
* 上限(30)钉住、由 `REFRESH_CONCURRENCY` 收敛成约 8 波,总预算随条数走。港美股每个市场
|
|
18
|
+
* 一次批量(≤50),比 A 股 ETF 逐只便宜。
|
|
16
19
|
* - 清单的读-改-写在**进程内串行**(`serialize`):出网不进这一道,跨进程只到"同一键"为止,
|
|
17
20
|
* 两条边界都写在 `serialize` 的注释里,不假装我们锁住了磁盘。
|
|
18
21
|
*/
|
|
19
22
|
import z from 'zod'
|
|
20
23
|
import { DEFAULT_FUYAO_BASE_URL, FuyaoError, fuyaoItems, fuyaoQuery, fuyaoRequest, fuyaoTimestamp } from '../lib/sources/fuyao-core.js'
|
|
24
|
+
// 港美股快照、指数点位与检索复用插件侧那一份内核(同一条节流链、同一张字段位序表、同一套守卫):
|
|
25
|
+
// 复制第二份解析就是 AGENTS.md §9.7 那一族事故,`test/watchlist-quote.test.mjs` 有模块同一性探针。
|
|
26
|
+
import {
|
|
27
|
+
fetchOffshoreIndexQuotes,
|
|
28
|
+
fetchOffshoreQuotes,
|
|
29
|
+
fetchSmartbox,
|
|
30
|
+
normalizeHkCode,
|
|
31
|
+
} from '../lib/sources/tencent-public-core.js'
|
|
21
32
|
|
|
22
33
|
export const name = 'capital-watchlist'
|
|
23
34
|
|
|
@@ -39,13 +50,39 @@ export const DOMAIN_NAME = 'capital_watchlist'
|
|
|
39
50
|
export const MAX_ITEMS = 30
|
|
40
51
|
/** ETF 扇出的并发路数:30 个单只请求压成约 8 波,正常网络一次刷新几秒内落地。 */
|
|
41
52
|
export const REFRESH_CONCURRENCY = 4
|
|
42
|
-
/**
|
|
43
|
-
export const SEARCH_LIMIT =
|
|
44
|
-
/**
|
|
53
|
+
/** 搜索候选上限(一屏给这么多,倒逼用户缩小输入,不做分页)。 */
|
|
54
|
+
export const SEARCH_LIMIT = 20
|
|
55
|
+
/**
|
|
56
|
+
* 每一路自己能拿到几条——`truncated` 那句"还有更多"按这一表判,不是按 `SEARCH_LIMIT` 判:
|
|
57
|
+
* Fuyao `ticker_search` 的 `limit` 官方取值 1~50(默认 10),这里取到与显示上限齐平;
|
|
58
|
+
* smartbox 实测**硬顶 10 条**(`&n=` / `&count=` / `&size=` / `&limit=` / `&max=` / `&hits=` 六种写法
|
|
59
|
+
* 全无效,2026-10-07 逐条实测),多要也不会多给。所以某一路回满自己那一档,就是上游那侧被砍了。
|
|
60
|
+
*/
|
|
61
|
+
export const SEARCH_BRANCH_LIMITS = { 'a-share': 20, hk: 10, us: 10 }
|
|
62
|
+
/** A 股那一路的搜索白名单(Fuyao 与报价端点双端都有可靠支撑);场外基金 / 北交所不进候选。
|
|
63
|
+
* 港美股那两路各有自己的白名单(`SMARTBOX_STOCK_TYPES` + `SMARTBOX_INDEX_TYPE`),互不通用。 */
|
|
45
64
|
export const SEARCH_ASSET_TYPES = 'a-share,a-share-index,fund-etf'
|
|
46
|
-
const ASSET_TYPES = ['a-share', 'a-share-index', 'fund-etf']
|
|
47
|
-
|
|
65
|
+
const ASSET_TYPES = ['a-share', 'a-share-index', 'fund-etf', 'hk-stock', 'hk-index', 'us-stock', 'us-index']
|
|
66
|
+
/**
|
|
67
|
+
* `exchange` 这一格存的是**市场**而不是交易所:A 股沿用 `SH`/`SZ`,港美股落 `HK`/`US`。
|
|
68
|
+
* 具体交易所(美股的 `OQ` / `N` / `AM` / 粉单 `PS`)留在 canonical 代码里(`AAPL.OQ`)——
|
|
69
|
+
* 这两个维度在三个市场里不是同一件事(港股没有交易所分裂、美股的点是级别码还是交易所在
|
|
70
|
+
* 字面上分不开),塞进同一格就会有一格在说谎。
|
|
71
|
+
*/
|
|
72
|
+
const EXCHANGES = ['SH', 'SZ', 'HK', 'US']
|
|
48
73
|
const SEARCH_TIMEOUT_MS = 15_000
|
|
74
|
+
/**
|
|
75
|
+
* smartbox 可用的市场段:只能是单值。实测 `t=hk,us` 这种列表形态回 `N` 哨兵——与"查无此票"
|
|
76
|
+
* 同一个回文,所以段值必须是这里的常量,不能让一个拼错的值伪装成"没有匹配的标的"。
|
|
77
|
+
*/
|
|
78
|
+
const SMARTBOX_MARKETS = ['hk', 'us']
|
|
79
|
+
/** smartbox 的类型段白名单:`GP` 个股、`GP-FUND` 基金类(港美股 ETF 都落在这两档)、`ZS` 指数。 */
|
|
80
|
+
const SMARTBOX_STOCK_TYPES = ['GP', 'GP-FUND']
|
|
81
|
+
const SMARTBOX_INDEX_TYPE = 'ZS'
|
|
82
|
+
/** 指数码与美股身份码的形状判据:与内核 `normalizeOffshoreIndexCode` / `parseUsQuoteRows` 的守卫同一条
|
|
83
|
+
* (尾段同样排除我们自己占用的四个市场标记,见下面 `KEY_FORMS` 里那条注释)。 */
|
|
84
|
+
const SMARTBOX_INDEX_CODE = /^[A-Z][A-Z0-9.]{1,9}$/u
|
|
85
|
+
const SMARTBOX_US_IDENTITY = /^[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3}$/u
|
|
49
86
|
/**
|
|
50
87
|
* 一次刷新的总预算:**随清单条数走**——`20s + 2.5s × 条数`,下界 45 秒、上界 90 秒。
|
|
51
88
|
* 下界保住 10 条这一档与 2.5.2 的行为完全一致(20 + 2.5×10 = 45),抬到 30 条时不必把小清单
|
|
@@ -75,16 +112,117 @@ const ENDPOINTS = {
|
|
|
75
112
|
}
|
|
76
113
|
|
|
77
114
|
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
115
|
+
* A 股那三档报价模板(**一字未动**地沿用 2.6 的行为:同一端点、同一批 / 逐只形状、同一套参数白名单)。
|
|
116
|
+
* `currency` 这一格是全仓唯一写 `CNY` 的地方,指数档故意不写(点位无量纲,R9)。
|
|
117
|
+
*/
|
|
118
|
+
const FUYAO_TEMPLATES = [
|
|
119
|
+
['a-share', { path: ENDPOINTS.stock, allowed: ['thscodes'], batch: true, scope: 'a-share', currency: 'CNY' }],
|
|
120
|
+
['a-share-index', { path: ENDPOINTS.index, allowed: ['thscodes'], batch: true, scope: 'a-share' }],
|
|
121
|
+
['fund-etf', { path: ENDPOINTS.fund, allowed: ['thscode'], batch: false, scope: 'a-share', currency: 'CNY' }],
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 港美股四档的规格:一个市场一族行类 = 一次批量(清单上限 30,实测的 ≤50 之内用不到分片)。
|
|
126
|
+
* `index` 决定走内核哪一个取数编排(两市的字段位序表都在内核里,面板不抄第二份);
|
|
127
|
+
* `withCurrency` 只在个股档为真——指数档即便上游在同一列塞了 `HKD` / `USD` 也不收(§3.5)。
|
|
128
|
+
*/
|
|
129
|
+
const OFFSHORE_SPECS = [
|
|
130
|
+
{ assetType: 'hk-stock', market: 'hk', index: false, withCurrency: true, scope: 'hk' },
|
|
131
|
+
{ assetType: 'hk-index', market: 'hk', index: true, withCurrency: false, scope: 'hk' },
|
|
132
|
+
{ assetType: 'us-stock', market: 'us', index: false, withCurrency: true, scope: 'us' },
|
|
133
|
+
{ assetType: 'us-index', market: 'us', index: true, withCurrency: false, scope: 'us' },
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 一组港美股代码 → 内核的取数入参。
|
|
138
|
+
*
|
|
139
|
+
* canonical 键**不能**直接当请求写法:美股的键是上游回显的带后缀身份(`AAPL.OQ`),而快照只认
|
|
140
|
+
* 不带后缀的那个写法(实测 `usAAPL.OQ` 整行不回、`usAAPL` 回)。这里的剥法与内核参数层的
|
|
141
|
+
* `normalizeUsCode` **不是同一条规则**,也不能是同一条:参数层要在"级别码"(BRK.B)与
|
|
142
|
+
* "交易所码"(BABA.N)之间猜,所以单字母尾段必须留着;而面板存的就是回显身份,最后一个点号
|
|
143
|
+
* 之后那一段**已知**是交易所码,剥掉即可,不用猜、也不用多一轮请求。
|
|
144
|
+
* 剥完仍不认识的那几只由内核的三轮写法回退保兜(`BRK.B` → 没回行再退 `BRK`)。
|
|
145
|
+
*/
|
|
146
|
+
function offshoreGroup(spec, keys) {
|
|
147
|
+
const codeByKey = new Map()
|
|
148
|
+
const failures = []
|
|
149
|
+
for (const thscode of keys) {
|
|
150
|
+
const stem = stemOf(thscode)
|
|
151
|
+
const code = spec.market === 'us' && !spec.index ? stem.replace(/\.[A-Z]{2,3}$/u, '') : stem
|
|
152
|
+
if (!/^(?:\d{5}|[A-Z][A-Z0-9.]{1,9})$/u.test(code)) {
|
|
153
|
+
// 指不到一个可请求的写法:这一条不进组、照实记 `quote_unavailable`,绝不拿半截代码去问
|
|
154
|
+
// (键形状本该挡住这种行,走到这里说明磁盘上的键与形状表已经不对账了)。
|
|
155
|
+
failures.push({ thscode, code: 'quote_unavailable' })
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
codeByKey.set(thscode, code)
|
|
159
|
+
}
|
|
160
|
+
if (codeByKey.size === 0) return { group: undefined, failures }
|
|
161
|
+
return { group: { ...spec, kind: 'tencent', keys: [...codeByKey.keys()], codes: [...new Set(codeByKey.values())] }, failures }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 回包第 2 位(上游自己的身份)→ 本仓的 canonical 键。港股两个市场标记本来就是我们加的。 */
|
|
165
|
+
function offshoreKeyOf(row, group) {
|
|
166
|
+
const code = typeof row.code === 'string' ? row.code.trim().toUpperCase() : ''
|
|
167
|
+
if (code.length === 0) return ''
|
|
168
|
+
if (group.market === 'hk') return `${code}.HK`
|
|
169
|
+
return group.index ? `${code}.US` : code
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 播种条目:三个市场各给一根"今天有没有开盘"的标尺。A 股那条走 Fuyao 的 `index` 端点,
|
|
174
|
+
* 恒指走腾讯港股指数窄表,纳指/标普走腾讯美股指数窄表——四条均已用 2026-10-07 真报文验证
|
|
175
|
+
* 有值(`usINX` 是标普在腾讯的写法,`SPX` / `usSSPX` 不回指数,见设计文档 P18 / P19)。
|
|
176
|
+
* 2026-10-07 由用户定为这四条,替换掉原先的深证成指 / 创业板指 / 沪深300。
|
|
177
|
+
* ⚠ 只对新域生效:老用户磁盘上仍是旧四条,**不补种、不替换**(清单是用户资产,
|
|
178
|
+
* 我们没有任何依据替他改掉三行)。
|
|
80
179
|
*/
|
|
81
180
|
export const SEED_ITEMS = [
|
|
82
181
|
{ thscode: '000001.SH', ticker: '000001', name: '上证指数', exchange: 'SH', asset_type: 'a-share-index' },
|
|
83
|
-
{ thscode: '
|
|
84
|
-
{ thscode: '
|
|
85
|
-
{ thscode: '
|
|
182
|
+
{ thscode: 'HSI.HK', ticker: 'HSI', name: '恒生指数', exchange: 'HK', asset_type: 'hk-index' },
|
|
183
|
+
{ thscode: 'IXIC.US', ticker: 'IXIC', name: '纳斯达克', exchange: 'US', asset_type: 'us-index' },
|
|
184
|
+
{ thscode: 'INX.US', ticker: 'INX', name: '标普500', exchange: 'US', asset_type: 'us-index' },
|
|
86
185
|
]
|
|
87
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 五种 canonical 键形状(设计文档 §3.4)。**判序有语义**,不是写法偏好:
|
|
189
|
+
* `HSI.HK` 若先落进字母分支、`.US` 若排在个股分支之后,都会得到一行"能加进清单、永远取不到数"
|
|
190
|
+
* 的条目(更糟的是取到**别人的**数,见 P19 的 `SSPX.AM`——它的 stem 与那只 ETF 完全相等)。
|
|
191
|
+
* 所以这一张表就是 `keyFormOf()` 的遍历顺序,顺序即规则;改顺序必须先回答"哪两串会互相吃掉"。
|
|
192
|
+
* 第一档 `a-share` 是唯一一"形"对多"类"(个股 / 指数 / ETF 同形),故 `assetTypes` 是集合:
|
|
193
|
+
* 这一族的 `asset_type` 只能来自搜索侧给的值,形状只负责否证。
|
|
194
|
+
*/
|
|
195
|
+
const KEY_FORMS = [
|
|
196
|
+
{ form: 'a-share', regex: /^\d{6}\.(SH|SZ)$/u, assetTypes: ['a-share', 'a-share-index', 'fund-etf'] },
|
|
197
|
+
{ form: 'hk-stock', regex: /^\d{5}\.HK$/u, assetTypes: ['hk-stock'] },
|
|
198
|
+
{ form: 'hk-index', regex: /^[A-Z][A-Z.]{0,9}\.HK$/u, assetTypes: ['hk-index'] },
|
|
199
|
+
{ form: 'us-index', regex: /^[A-Z]{2,8}\.US$/u, assetTypes: ['us-index'] },
|
|
200
|
+
// 尾段排除 `SH` / `SZ` / `HK` / `US` 这四个**我们自己占用的市场标记**:不放行的话
|
|
201
|
+
// `ABCDEF.SH` 会同时命中"美股身份"那一档(`[A-Z]{1,3}` 装得下任何两个字母),
|
|
202
|
+
// 于是一个不存在的键能进清单、还能去敲腾讯的门。交易所码本身的取值域不归我们发明
|
|
203
|
+
// (实测有 OQ / N / AM / PS,内核 `parseUsQuoteRows` 因此只约束长度),这一条只收自己的账。
|
|
204
|
+
{ form: 'us-stock', regex: /^[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3}$/u, assetTypes: ['us-stock'] },
|
|
205
|
+
]
|
|
206
|
+
/** 命中即返回;不返回"最像的那档"——判不中就是不是我们认得的键。 */
|
|
207
|
+
export function keyFormOf(thscode) {
|
|
208
|
+
if (typeof thscode !== 'string') return null
|
|
209
|
+
return KEY_FORMS.find((entry) => entry.regex.test(thscode)) ?? null
|
|
210
|
+
}
|
|
211
|
+
const KEY_REGEX = /^(?:\d{6}\.(?:SH|SZ)|\d{5}\.HK|[A-Z][A-Z.]{0,9}\.HK|[A-Z]{2,8}\.US|[A-Z][A-Z.]{0,9}\.(?!SH$|SZ$|HK$|US$)[A-Z]{1,3})$/u
|
|
212
|
+
/**
|
|
213
|
+
* `not_found` 那句话里要说清"这一档支持什么"(市场名从键的形状读,不写死"沪深 A 股 / 指数 /
|
|
214
|
+
* 场内 ETF"——那句在港美股进来之后会指错路)。指数两档要把"写法不许猜"写进用户看得见的句子:
|
|
215
|
+
* 实测 `usSPX` / `usNDQ` / `usSSPX` 都不回指数(最后那个回了一只真 ETF 且不报错),
|
|
216
|
+
* 只有搜索回来的那一串才被腾讯认。
|
|
217
|
+
*/
|
|
218
|
+
const FORM_SCOPE_TEXT = {
|
|
219
|
+
'a-share': '沪深 A 股 / 指数 / 场内 ETF',
|
|
220
|
+
'hk-stock': '港股现货',
|
|
221
|
+
'hk-index': '港股指数,写法只认搜索回来的那个',
|
|
222
|
+
'us-stock': '美股与在美 ADR',
|
|
223
|
+
'us-index': '美股指数,写法只认搜索回来的那个(标普是 INX、纳斯达克是 IXIC)',
|
|
224
|
+
}
|
|
225
|
+
|
|
88
226
|
const quoteSchema = z.object({
|
|
89
227
|
price: z.number(),
|
|
90
228
|
/**
|
|
@@ -96,10 +234,24 @@ const quoteSchema = z.object({
|
|
|
96
234
|
change_pct: z.number().nullable(),
|
|
97
235
|
captured_at: z.number().int(),
|
|
98
236
|
source_ts: z.number().int().nullable(),
|
|
237
|
+
/**
|
|
238
|
+
* 币种**只跟着钱走**:个股与 ETF 每份回包都带(港美股从上游那一列读、A 股由本文件唯一一处写
|
|
239
|
+
* `CNY`),指数族一律不落——点位是无量纲的数,腾讯确实在指数行的同一位塞了 `HKD` / `USD`,
|
|
240
|
+
* 画上去等于替上游说了一句它没说的话(设计文档 §3.5 / R9)。
|
|
241
|
+
* 取值域**不是闭集**:这个列归上游所有(今天已看见 `HKD` / `USD` / `CNY` 三种,`-R` 柜台就是
|
|
242
|
+
* CNY 报价的港股),我们不拥有它,所以只限形状不限内容,面板对不认识的币种照字面画。
|
|
243
|
+
*/
|
|
244
|
+
currency: z.string().min(1).max(8).optional(),
|
|
245
|
+
/**
|
|
246
|
+
* 交易所**当地时间**的原串(`2026-10-05 16:08:09`,HKT / 美东),只有港美股那一路有;
|
|
247
|
+
* `source_ts`(epoch)只由 Fuyao 信封填。两者不互相伪造、也不互相换算——换算要用到我们没有的
|
|
248
|
+
* 偏移表与夏令时口径(§7-N4)。页脚那句"刷新于此刻"讲的仍是我们的取数时刻(`captured_at`)。
|
|
249
|
+
*/
|
|
250
|
+
source_time: z.string().max(32).nullable().optional(),
|
|
99
251
|
})
|
|
100
252
|
|
|
101
253
|
const itemSchema = z.object({
|
|
102
|
-
thscode: z.string().regex(
|
|
254
|
+
thscode: z.string().regex(KEY_REGEX),
|
|
103
255
|
ticker: z.string().min(1),
|
|
104
256
|
name: z.string().min(1),
|
|
105
257
|
exchange: z.enum(EXCHANGES),
|
|
@@ -133,18 +285,55 @@ export const domainSpec = {
|
|
|
133
285
|
tables: { items: { valueSchema: itemSchema } },
|
|
134
286
|
}
|
|
135
287
|
|
|
136
|
-
/** 规范化
|
|
288
|
+
/** 规范化 canonical 键:trim + 大写,只接受 §3.4 的五种形状。裸代码一律拒绝——`AAPL` 少了交易所段就
|
|
289
|
+
* 没法和回显身份核对,`000001` 到底是指数还是平安银行由上游裁决、不由我们猜。 */
|
|
137
290
|
export function normalizeThscode(value) {
|
|
138
291
|
if (typeof value !== 'string') return undefined
|
|
139
292
|
const cleaned = value.trim().toUpperCase()
|
|
140
|
-
return
|
|
293
|
+
return KEY_REGEX.test(cleaned) ? cleaned : undefined
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 取"最后一个点号之前"那一段。**⛔ 不许用 `split('.')[0]` / `split('.')[1]`**:
|
|
298
|
+
* `BRK.B.N` 会拆成 `['BRK','B','N']`——实测那是一级股票(伯克希尔 B)而不是畸形代码。
|
|
299
|
+
* 这一串同时是三处共用的"检索/请求写法":smartbox 只认它(把 canonical 键原样丢过去一律回
|
|
300
|
+
* `N` 哨兵,2026-10-07 实测 `TCEHY.PS` / `00700.HK` / `HSI.HK` / `IXIC.US` 全部不回行),
|
|
301
|
+
* 美指数用 `IXIC.US` → `IXIC` 也正好是腾讯认的写法(回显是 `.IXIC`,差一个前导点)。
|
|
302
|
+
*/
|
|
303
|
+
export function stemOf(thscode) {
|
|
304
|
+
const dot = thscode.lastIndexOf('.')
|
|
305
|
+
return dot > 0 ? thscode.slice(0, dot) : thscode
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 键所属市场(`SH` / `SZ` / `HK` / `US`),只从形状的**尾段**取,不从中间的点号取。 */
|
|
309
|
+
function marketOfKey(thscode) {
|
|
310
|
+
const dot = thscode.lastIndexOf('.')
|
|
311
|
+
return dot > 0 ? thscode.slice(dot + 1) : ''
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* 这一行归哪一路报价:`'fuyao'` 或 `'tencent'`(后者带市场)。判据是**键的形状**而不是 `asset_type`
|
|
316
|
+
* 那一格——形状由 `KEY_FORMS` 的判序给出,与加进来时用的是同一份判据,磁盘上被人手改过的
|
|
317
|
+
* `asset_type` 不能让一行港股悄悄流进 Fuyao 端点(那边根本不认它)。
|
|
318
|
+
*/
|
|
319
|
+
function quoteRoute(item) {
|
|
320
|
+
const entry = keyFormOf(item.thscode)
|
|
321
|
+
if (entry === null) return undefined
|
|
322
|
+
return entry.assetTypes.includes(item.asset_type) ? entry : undefined
|
|
141
323
|
}
|
|
142
324
|
|
|
143
325
|
/**
|
|
144
|
-
*
|
|
326
|
+
* 上游错误 → 本路由自己的错误码。`perItem` 决定"这个标的取不到"还是"这一路不通":
|
|
145
327
|
* 逐条失败绝不能升格成整批失败,整批失败也绝不能伪装成某一条的缺口。
|
|
328
|
+
*
|
|
329
|
+
* `source` 决定**哪一家**不通:两路的处置动作完全不同——Fuyao 那一路要去看密钥卡片,
|
|
330
|
+
* 腾讯那一路无密钥可查(公开端点,只可能是上游或网络)。混成一条就是叫用户去配一个
|
|
331
|
+
* 本来不需要的 key。腾讯侧的判定读内核挂在 Error 上的 `code`(`tencent_*`):这一家
|
|
332
|
+
* 没有"凭据"与"限流"之外的可处置区分,所以报文畸形与传输失败在**批级**都说成
|
|
333
|
+
* `tencent_unavailable`,不假装我们能指出是哪一只。
|
|
146
334
|
*/
|
|
147
|
-
export function watchlistErrorCode(error, { perItem = false } = {}) {
|
|
335
|
+
export function watchlistErrorCode(error, { perItem = false, source = 'fuyao' } = {}) {
|
|
336
|
+
if (source === 'tencent') return perItem ? 'quote_unavailable' : (error?.code === 'tencent_rate_limit' ? 'rate_limited' : 'tencent_unavailable')
|
|
148
337
|
if (!(error instanceof FuyaoError)) return perItem ? 'quote_unavailable' : 'fuyao_unavailable'
|
|
149
338
|
switch (error.kind) {
|
|
150
339
|
case 'credential_missing':
|
|
@@ -181,17 +370,61 @@ function rowsOf(table) {
|
|
|
181
370
|
.map((entry) => entry.value)
|
|
182
371
|
}
|
|
183
372
|
|
|
373
|
+
/** Fuyao 候选行 → 本面板的候选形状。 */
|
|
184
374
|
function candidateOf(row) {
|
|
185
375
|
const thscode = typeof row.thscode === 'string' ? row.thscode : ''
|
|
186
376
|
return {
|
|
187
377
|
thscode,
|
|
188
|
-
ticker: typeof row.ticker === 'string' && row.ticker.length > 0 ? row.ticker : thscode
|
|
378
|
+
ticker: typeof row.ticker === 'string' && row.ticker.length > 0 ? row.ticker : stemOf(thscode),
|
|
189
379
|
name: typeof row.name === 'string' && row.name.length > 0 ? row.name : thscode,
|
|
190
|
-
|
|
380
|
+
// ⛔ 市场优先取上游给的值(Fuyao 给 `exchange`、smartbox 给市场段),字符串推导只当兜底,
|
|
381
|
+
// 且只能取**最后一个**点号之后:`BRK.B.N` 用 `split('.')[1]` 会取出 `'B'`。
|
|
382
|
+
exchange: typeof row.exchange === 'string' && row.exchange.length > 0 ? row.exchange.toUpperCase() : marketOfKey(thscode),
|
|
191
383
|
asset_type: typeof row.asset_type === 'string' ? row.asset_type : '',
|
|
192
384
|
}
|
|
193
385
|
}
|
|
194
386
|
|
|
387
|
+
/**
|
|
388
|
+
* smartbox 一行候选 → 本面板的候选形状;判不成立回 `undefined`(**不进候选**,不报错)。
|
|
389
|
+
*
|
|
390
|
+
* 两条闸门叠在一起用(设计文档 §3.6):类型白名单收掉衍生品,键形状收掉"类型说的和形状
|
|
391
|
+
* 不是一回事"的行。市场段由调用方给(`t=hk` / `t=us` 各一次请求),所以这里不再从 `row.market`
|
|
392
|
+
* 猜——实测 `t=hk&q=纳指` 十条里七条是窝轮,白名单必须事后收而不是指望上游收。
|
|
393
|
+
* 名称直接用上游给的(smartbox 的 `~` 第 3 位已是解过 `\uXXXX` 的正文)。
|
|
394
|
+
*/
|
|
395
|
+
function offshoreCandidateOf(row, market) {
|
|
396
|
+
if (typeof row?.code !== 'string' || row.code.length === 0) return undefined
|
|
397
|
+
if (!SMARTBOX_STOCK_TYPES.includes(row.type) && row.type !== SMARTBOX_INDEX_TYPE) return undefined
|
|
398
|
+
const isIndex = row.type === SMARTBOX_INDEX_TYPE
|
|
399
|
+
// 实测美指数行给的是 `ixic`(不带上游回显时那个前导点);两种写法都收,判形状后统一成裸码。
|
|
400
|
+
const code = row.code.trim().toUpperCase().replace(/^\./u, '')
|
|
401
|
+
const stem = isIndex
|
|
402
|
+
? (SMARTBOX_INDEX_CODE.test(code) ? code : undefined)
|
|
403
|
+
: market === 'hk'
|
|
404
|
+
? fiveDigitHk(code)
|
|
405
|
+
: (SMARTBOX_US_IDENTITY.test(code) ? code : undefined)
|
|
406
|
+
if (stem === undefined) return undefined
|
|
407
|
+
// 美股个股的键**就是**上游回显的身份(`TCEHY.PS` / `BRK.B.N`,实测 smartbox 每行都带后缀),
|
|
408
|
+
// 不再补市场标记;其余四档都补(港股上游只回 `00700` / `HSI`,`.HK` 本来就是我们加的)。
|
|
409
|
+
const thscode = market === 'us' && !isIndex ? stem : `${stem}.${market === 'hk' ? 'HK' : 'US'}`
|
|
410
|
+
const assetType = market === 'hk' ? (isIndex ? 'hk-index' : 'hk-stock') : isIndex ? 'us-index' : 'us-stock'
|
|
411
|
+
const entry = keyFormOf(thscode)
|
|
412
|
+
// ⛔ 形状与类型必须互相认账:`ZS` 行给成个股形状(或反过来)说明这一行不是我们能表达的行类,
|
|
413
|
+
// 收进清单就是一行"加得进去、永远取不到数"的条目。宁可少一条候选。
|
|
414
|
+
if (entry === null || !entry.assetTypes.includes(assetType)) return undefined
|
|
415
|
+
const name = typeof row.name === 'string' && row.name.length > 0 ? row.name : thscode
|
|
416
|
+
return { thscode, ticker: stemOf(thscode), name, exchange: market === 'hk' ? 'HK' : 'US', asset_type: assetType }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** 港股代码的"5 位左补零"只有一份实现(内核 `normalizeHkCode`);判不成立回 `undefined`,不抛。 */
|
|
420
|
+
function fiveDigitHk(code) {
|
|
421
|
+
try {
|
|
422
|
+
return normalizeHkCode(code, 'smartbox.code')
|
|
423
|
+
} catch {
|
|
424
|
+
return undefined
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
195
428
|
/**
|
|
196
429
|
* 上游用 `null` 表示"这个数没有"(本仓 `fuyao-rest.ts` 的 `matchesField` 就是为此放行 null 的,
|
|
197
430
|
* 理由是不能让一个指标缺失打挂整份数据)。这里绝不能拿 `Number()` 硬转:`Number(null)` 是 **0**,
|
|
@@ -205,11 +438,11 @@ function numberValue(value) {
|
|
|
205
438
|
return Number.isFinite(parsed) ? parsed : undefined
|
|
206
439
|
}
|
|
207
440
|
|
|
208
|
-
function quoteOf(row, sourceTs, capturedAt) {
|
|
441
|
+
function quoteOf(row, sourceTs, capturedAt, currency) {
|
|
209
442
|
const price = numberValue(row.last_price)
|
|
210
443
|
if (price === undefined) return undefined
|
|
211
444
|
const pct = numberValue(row.price_change_ratio_pct)
|
|
212
|
-
|
|
445
|
+
const quote = {
|
|
213
446
|
price,
|
|
214
447
|
// **没有涨跌就是没有**,不许补 0:`0` 在 A 股口径里是一个真值(今日平盘),把它写成
|
|
215
448
|
// 停牌 / 未开盘的标的,等于伪造一份"横盘"的新快照顶掉上一次的真涨跌,还不记任何失败。
|
|
@@ -218,6 +451,37 @@ function quoteOf(row, sourceTs, capturedAt) {
|
|
|
218
451
|
captured_at: capturedAt,
|
|
219
452
|
source_ts: typeof sourceTs === 'number' ? sourceTs : null,
|
|
220
453
|
}
|
|
454
|
+
// 全仓**唯一**一处写 `CNY` 的地方:Fuyao 的快照回包里没有币种这一列,而面板要每只个股与 ETF
|
|
455
|
+
// 都说清币种(R9)。指数族不写——点位无量纲。港美股那一路的币种从回包取,见 `offshoreQuoteOf`。
|
|
456
|
+
if (currency !== undefined) quote.currency = currency
|
|
457
|
+
return quote
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* 腾讯回包一行 → 报价。三条口径与 A 股那一路不同,都在设计文档 §3.5:
|
|
462
|
+
* - **涨跌**:港股/美股停牌与半日市上游回的是 `+0.00%`(实测 `hk00465`),判据只用内核算好的
|
|
463
|
+
* `is_stale`(成交额 0 且现价等于昨收)落成 `null`,不自建"接近 0 就当没有"的猜测。
|
|
464
|
+
* 指数行没有 `is_stale` 这一格(内核不给成交额),所以平盘的指数照实回 `0.00`——那是真值。
|
|
465
|
+
* - **币种**:只有个股/ETF 读 `[75]` / `[35]`(实测 `-R` 柜台那批是 `CNY`,按市场猜必错);
|
|
466
|
+
* 指数族一律不写,上游在同一位置塞了 `HKD` / `USD` 也不接(点位不是钱)。
|
|
467
|
+
* - **时刻**:`source_time` 存交易所当地原串,`source_ts`(epoch)留给 Fuyao 那一路,两边不互相
|
|
468
|
+
* 伪造、不换算时区。
|
|
469
|
+
*/
|
|
470
|
+
function offshoreQuoteOf(row, capturedAt, { withCurrency }) {
|
|
471
|
+
const price = numberValue(row.price)
|
|
472
|
+
if (price === undefined) return undefined
|
|
473
|
+
const stale = row.is_stale === true
|
|
474
|
+
const pct = stale ? undefined : numberValue(row.change_pct)
|
|
475
|
+
const quote = {
|
|
476
|
+
price,
|
|
477
|
+
change_pct: pct ?? null,
|
|
478
|
+
captured_at: capturedAt,
|
|
479
|
+
source_ts: null,
|
|
480
|
+
source_time: typeof row.quote_time === 'string' ? row.quote_time : null,
|
|
481
|
+
}
|
|
482
|
+
const currency = withCurrency && typeof row.currency === 'string' ? row.currency.trim().toUpperCase() : ''
|
|
483
|
+
if (currency.length > 0) quote.currency = currency
|
|
484
|
+
return quote
|
|
221
485
|
}
|
|
222
486
|
|
|
223
487
|
/**
|
|
@@ -305,6 +569,15 @@ export function createWatchlistService(options = {}) {
|
|
|
305
569
|
return fuyaoRequest({ baseUrl, apiKey, path, search: fuyaoQuery(params, allowed), signal: AbortSignal.timeout(timeoutMs) })
|
|
306
570
|
}
|
|
307
571
|
|
|
572
|
+
/**
|
|
573
|
+
* 一次输入三路并发:Fuyao `ticker_search` 一笔(`asset_type` 白名单照旧,`limit` 随显示上限抬到
|
|
574
|
+
* 20——官方参数面 1~50,不是我们自己放宽的)\+ smartbox 两笔(`t=hk` / `t=us`,各硬顶 10 条)。
|
|
575
|
+
* 为什么 smartbox 不共用一次 `t=all`:实测 `t=all&q=富通`
|
|
576
|
+
* 回 9 条 A 股 + 1 条 hk + **0 条 us**,`q=00700` 只回 1 条 hk——共用一次会让"美股这一档根本没进
|
|
577
|
+
* 候选"从偶发变成常态,那是"入口统一"最直接的反面(设计文档 P17 / §3.2)。
|
|
578
|
+
* 每一路各自失败只进 `partial`,**只有三路全挂**才整块失败:没配 Fuyao 密钥时港美股照常可加
|
|
579
|
+
* 可刷(R12),`credential_missing` 的射程因此从整块面板缩到 A 股那一路。
|
|
580
|
+
*/
|
|
308
581
|
async function search(query) {
|
|
309
582
|
const q = typeof query === 'string' ? query.trim() : ''
|
|
310
583
|
if (q.length === 0 || q.length > MAX_QUERY_LENGTH) {
|
|
@@ -313,25 +586,76 @@ export function createWatchlistService(options = {}) {
|
|
|
313
586
|
// 先碰存储再出网:域打不开时 `/search` 不该白烧一次上游配额(候选也根本写不进清单)。
|
|
314
587
|
// 存储失败因此从这一路也走响亮那条(路由层的逐次 catch → store_unavailable),不靠预热闩住。
|
|
315
588
|
const items = await table()
|
|
316
|
-
|
|
589
|
+
// `SMARTBOX_MARKETS` 是常量表,`t` 的值没有第二条来路:实测上游对不认识的 `t` 回的是 `N`
|
|
590
|
+
// 哨兵——与"查无此票"同一个回文,拼错一次就是静默少一路(§3.6)。
|
|
591
|
+
const branches = await Promise.all([searchAShare(q), ...SMARTBOX_MARKETS.map((market) => searchOffshore(q, market))])
|
|
592
|
+
const failures = branches.filter((branch) => branch.ok !== true)
|
|
593
|
+
if (failures.length === branches.length) {
|
|
594
|
+
// 全挂才占用居中位。代码挑"用户还能动手"的那一路:密钥 → 限流 → 服务不通。
|
|
595
|
+
const worst = worstProblem(failures)
|
|
596
|
+
return { ok: false, code: worst.code, message: messageFor(worst.code, 'all'), scope: 'all' }
|
|
597
|
+
}
|
|
598
|
+
const merged = mergeCandidates(branches.filter((branch) => branch.ok === true).flatMap((branch) => branch.rows), q)
|
|
599
|
+
const result = {
|
|
600
|
+
ok: true,
|
|
601
|
+
items: merged.slice(0, SEARCH_LIMIT).map((row) => ({ ...row, in_list: items.get(row.thscode) !== undefined })),
|
|
602
|
+
// 两种"还有更多"都要说实话:某一路自己回满了**它那一档**的上限(上游那侧被砍),或合并后
|
|
603
|
+
// 超过一屏(我们这一侧被砍)。两档数字不同,所以逐路按 `SEARCH_BRANCH_LIMITS` 判。
|
|
604
|
+
truncated: merged.length > SEARCH_LIMIT || branches.some((branch) => branch.ok === true && branch.rows.length >= SEARCH_BRANCH_LIMITS[branch.market]),
|
|
605
|
+
}
|
|
606
|
+
if (failures.length > 0) result.partial = failures.map((failure) => ({ market: failure.market, code: failure.code }))
|
|
607
|
+
return result
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** A 股那一路。⛔ 不许拿港美股那两条白名单顺手再过滤一遍:这边已有 `asset_type` + 键形状两道闸。 */
|
|
611
|
+
async function searchAShare(q) {
|
|
317
612
|
try {
|
|
318
|
-
envelope = await callFuyao(ENDPOINTS.search, { q, asset_type: SEARCH_ASSET_TYPES, limit:
|
|
613
|
+
const envelope = await callFuyao(ENDPOINTS.search, { q, asset_type: SEARCH_ASSET_TYPES, limit: SEARCH_BRANCH_LIMITS['a-share'] }, ['q', 'asset_type', 'limit'])
|
|
614
|
+
// 后缀也要收口:实测 `q=宁德时代` 会带回 `885789.TI 宁德时代概念`(同花顺指数),
|
|
615
|
+
// 它既不在本版报价范围、也过不了域 schema——不挡掉就是"添加"那一下 500。
|
|
616
|
+
return { ok: true, market: 'a-share', rows: fuyaoItems(envelope).map(candidateOf).filter((row) => ASSET_TYPES.includes(row.asset_type) && normalizeThscode(row.thscode) !== undefined) }
|
|
319
617
|
} catch (error) {
|
|
320
|
-
|
|
321
|
-
return { ok: false, code, message: messageFor(code) }
|
|
618
|
+
return { ok: false, market: 'a-share', code: watchlistErrorCode(error) }
|
|
322
619
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
ok: true,
|
|
330
|
-
|
|
331
|
-
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** smartbox 那两路之一:类型白名单 + 键形状一起收,衍生品(窝轮 `QZ`)不进候选。 */
|
|
623
|
+
async function searchOffshore(q, market) {
|
|
624
|
+
try {
|
|
625
|
+
const found = await fetchSmartbox(q, market, AbortSignal.timeout(timeoutMs))
|
|
626
|
+
return { ok: true, market, rows: found.map((row) => offshoreCandidateOf(row, market)).filter((row) => row !== undefined) }
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return { ok: false, market, code: watchlistErrorCode(error, { source: 'tencent' }) }
|
|
332
629
|
}
|
|
333
630
|
}
|
|
334
631
|
|
|
632
|
+
/**
|
|
633
|
+
* 合并三路:按 canonical 键去重(同一只票从任一路加过,另一路那条也标"已添加"),
|
|
634
|
+
* **输入完全等于代码的行排最前**(比对到 stem:`00700` 要认 `00700.HK`、`TCEHY` 要认 `TCEHY.PS`),
|
|
635
|
+
* 其余保持上游顺序(A 股 → 港 → 美)。`Array.prototype.sort` 是稳定排序,同分不换序。
|
|
636
|
+
*/
|
|
637
|
+
function mergeCandidates(rows, q) {
|
|
638
|
+
const seen = new Map()
|
|
639
|
+
for (const row of rows) {
|
|
640
|
+
if (row.thscode.length > 0 && !seen.has(row.thscode)) seen.set(row.thscode, row)
|
|
641
|
+
}
|
|
642
|
+
const needle = q.trim().toUpperCase()
|
|
643
|
+
return [...seen.values()].sort((left, right) => exactness(right, needle) - exactness(left, needle))
|
|
644
|
+
}
|
|
645
|
+
function exactness(row, needle) {
|
|
646
|
+
if (needle.length === 0) return 0
|
|
647
|
+
if (row.thscode === needle) return 2
|
|
648
|
+
return stemOf(row.thscode) === needle ? 1 : 0
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 三路全挂时挑一句最能指导动作的错误码。 */
|
|
652
|
+
function worstErrorCode(codes) {
|
|
653
|
+
for (const candidate of ['credential_missing', 'rate_limited', 'invalid_query', 'fuyao_unavailable', 'tencent_unavailable']) {
|
|
654
|
+
if (codes.includes(candidate)) return candidate
|
|
655
|
+
}
|
|
656
|
+
return codes[0] ?? 'fuyao_unavailable'
|
|
657
|
+
}
|
|
658
|
+
|
|
335
659
|
async function list() {
|
|
336
660
|
await ensureSeeded()
|
|
337
661
|
const opened = await domain()
|
|
@@ -351,11 +675,17 @@ export function createWatchlistService(options = {}) {
|
|
|
351
675
|
const direct = input?.thscode
|
|
352
676
|
if (direct !== undefined && direct !== null && direct !== '') {
|
|
353
677
|
const thscode = normalizeThscode(direct)
|
|
354
|
-
if (thscode === undefined)
|
|
355
|
-
|
|
678
|
+
if (thscode === undefined) {
|
|
679
|
+
return { ok: false, code: 'invalid_query', message: '代码格式不符:要带市场或交易所后缀的完整代码,例如 300750.SZ / 00700.HK / HSI.HK / AAPL.OQ / IXIC.US' }
|
|
680
|
+
}
|
|
681
|
+
const entry = keyFormOf(thscode)
|
|
682
|
+
// 存在性核对用**上游认的那个写法**问:A 股沿用带后缀的完整代码(Fuyao 那侧本来就是它给的身份),
|
|
683
|
+
// 港美股只能问 stem——实测把 canonical 键原样丢给 smartbox 一律回 `N` 哨兵(`00700.HK`、
|
|
684
|
+
// `TCEHY.PS`、`HSI.HK`、`IXIC.US` 全都不回行),拿它当"查无此票"会把一只真票判成不存在。
|
|
685
|
+
const found = await search(entry.form === 'a-share' ? thscode : stemOf(thscode))
|
|
356
686
|
if (!found.ok) return found
|
|
357
687
|
const hit = found.items.find((row) => row.thscode === thscode)
|
|
358
|
-
if (hit === undefined) return { ok: false, code: 'not_found', message:
|
|
688
|
+
if (hit === undefined) return { ok: false, code: 'not_found', message: `该标的不在本版支持范围(${FORM_SCOPE_TEXT[entry.form] ?? '可检索的证券代码'})` }
|
|
359
689
|
return serialize(() => {
|
|
360
690
|
// 出网最长 15 秒,这期间别的窗口可能已经填满:上限必须在"读 size → put"这一段的锁内复查。
|
|
361
691
|
if (items.size >= MAX_ITEMS) return full()
|
|
@@ -418,8 +748,12 @@ export function createWatchlistService(options = {}) {
|
|
|
418
748
|
})
|
|
419
749
|
}
|
|
420
750
|
|
|
421
|
-
/**
|
|
422
|
-
|
|
751
|
+
/** 一个分组 = 一次快照请求(只有场内 ETF 那一档是逐只);单组失败只记该组,不拖垮其它组。 */
|
|
752
|
+
function fetchGroup(group) {
|
|
753
|
+
return group.kind === 'tencent' ? fetchOffshoreGroup(group) : fetchFuyaoGroup(group)
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function fetchFuyaoGroup(group) {
|
|
423
757
|
const capturedAt = now()
|
|
424
758
|
let envelope
|
|
425
759
|
try {
|
|
@@ -429,20 +763,57 @@ export function createWatchlistService(options = {}) {
|
|
|
429
763
|
const batch = batchLevelCode(error)
|
|
430
764
|
return {
|
|
431
765
|
byCode: new Map(),
|
|
432
|
-
failures: group.
|
|
433
|
-
batchError: batch !== undefined ? { code: batch,
|
|
766
|
+
failures: group.keys.map((thscode) => ({ thscode, code })),
|
|
767
|
+
batchError: batch !== undefined ? { code: batch, scope: group.scope } : undefined,
|
|
434
768
|
}
|
|
435
769
|
}
|
|
436
770
|
const sourceTs = fuyaoTimestamp(envelope)
|
|
437
771
|
const byCode = new Map()
|
|
438
772
|
for (const row of fuyaoItems(envelope)) {
|
|
439
773
|
const thscode = typeof row.thscode === 'string' ? row.thscode : ''
|
|
440
|
-
const quote = quoteOf(row, sourceTs, capturedAt)
|
|
774
|
+
const quote = quoteOf(row, sourceTs, capturedAt, group.currency)
|
|
441
775
|
if (thscode !== '' && quote !== undefined) byCode.set(thscode, quote)
|
|
442
776
|
}
|
|
443
777
|
return {
|
|
444
778
|
byCode,
|
|
445
|
-
failures: group.
|
|
779
|
+
failures: group.keys.filter((thscode) => !byCode.has(thscode)).map((thscode) => ({ thscode, code: 'quote_unavailable' })),
|
|
780
|
+
batchError: undefined,
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 港美股一档 = 该市场这一族的一次批量(清单上限 30,永远在实测的 ≤50 之内)。
|
|
786
|
+
* 取数与守卫全部走内核(同一节流链、同一张字段位序表);这一层只负责**身份核对**:
|
|
787
|
+
* 回包第 2 位推回来的 canonical 键必须正是我们问出去的那一只,否则这一行**不落价**,
|
|
788
|
+
* 让它照实落在 `quote_unavailable`(设计文档 §3.4 / §3.9 第 4 点:实测 `usSSPX` 会回一只
|
|
789
|
+
* 真 ETF 且不报错,只比 stem 抓不住,形状由内核的守卫给、身份由这一句"必须是问出去的那只"给)。
|
|
790
|
+
* 这一句同时就是日志:面板那一格写着"取不到报价",用户看得见,不存在静默丢弃。
|
|
791
|
+
*/
|
|
792
|
+
async function fetchOffshoreGroup(group) {
|
|
793
|
+
const capturedAt = now()
|
|
794
|
+
let rows
|
|
795
|
+
try {
|
|
796
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
797
|
+
rows = group.index ? await fetchOffshoreIndexQuotes(group.market, group.codes, signal) : await fetchOffshoreQuotes(group.market, group.codes, signal)
|
|
798
|
+
} catch (error) {
|
|
799
|
+
const batch = batchLevelCode(error, 'tencent')
|
|
800
|
+
return {
|
|
801
|
+
byCode: new Map(),
|
|
802
|
+
failures: group.keys.map((thscode) => ({ thscode, code: watchlistErrorCode(error, { perItem: true, source: 'tencent' }) })),
|
|
803
|
+
batchError: batch !== undefined ? { code: batch, scope: group.scope } : undefined,
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const requested = new Set(group.keys)
|
|
807
|
+
const byCode = new Map()
|
|
808
|
+
for (const row of rows) {
|
|
809
|
+
const thscode = offshoreKeyOf(row, group)
|
|
810
|
+
if (!requested.has(thscode)) continue
|
|
811
|
+
const quote = offshoreQuoteOf(row, capturedAt, { withCurrency: group.withCurrency })
|
|
812
|
+
if (quote !== undefined) byCode.set(thscode, quote)
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
byCode,
|
|
816
|
+
failures: group.keys.filter((thscode) => !byCode.has(thscode)).map((thscode) => ({ thscode, code: 'quote_unavailable' })),
|
|
446
817
|
batchError: undefined,
|
|
447
818
|
}
|
|
448
819
|
}
|
|
@@ -469,20 +840,38 @@ export function createWatchlistService(options = {}) {
|
|
|
469
840
|
const batch = rowsOf(items)
|
|
470
841
|
if (batch.length === 0) return { ok: true, items: [], refreshed_at: null, failures: [] }
|
|
471
842
|
|
|
843
|
+
// 七档分组:A 股个股 / A 股指数各一次逗号批量、场内 ETF 逐只、港个股与港指数各一次批量、
|
|
844
|
+
// 美个股与美指数各一次批量。分派判据是**键的形状与 `asset_type` 互相认账**(同一张 `KEY_FORMS`
|
|
845
|
+
// 表,与加入清单时同一条),不认账的行不进任何一组、照实记 `quote_unavailable`——
|
|
846
|
+
// 磁盘上被人改过的那一格不能让一行港股流进 Fuyao 端点(那边根本不认它,反之亦然)。
|
|
472
847
|
const groups = []
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
848
|
+
const failures = []
|
|
849
|
+
const keysByType = new Map()
|
|
850
|
+
for (const row of batch) {
|
|
851
|
+
if (quoteRoute(row) === undefined) {
|
|
852
|
+
failures.push({ thscode: row.thscode, code: 'quote_unavailable' })
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
const list = keysByType.get(row.asset_type)
|
|
856
|
+
if (list === undefined) keysByType.set(row.asset_type, [row.thscode])
|
|
857
|
+
else list.push(row.thscode)
|
|
858
|
+
}
|
|
859
|
+
for (const [assetType, template] of FUYAO_TEMPLATES) {
|
|
860
|
+
const keys = keysByType.get(assetType) ?? []
|
|
861
|
+
if (keys.length === 0) continue
|
|
862
|
+
if (!template.batch) {
|
|
863
|
+
for (const thscode of keys) groups.push({ kind: 'fuyao', path: template.path, allowed: template.allowed, params: { thscode }, keys: [thscode], scope: template.scope, currency: template.currency })
|
|
482
864
|
} else {
|
|
483
|
-
groups.push({ path:
|
|
865
|
+
groups.push({ kind: 'fuyao', path: template.path, allowed: template.allowed, params: { thscodes: keys.join(',') }, keys, scope: template.scope, currency: template.currency })
|
|
484
866
|
}
|
|
485
867
|
}
|
|
868
|
+
for (const spec of OFFSHORE_SPECS) {
|
|
869
|
+
const keys = keysByType.get(spec.assetType) ?? []
|
|
870
|
+
if (keys.length === 0) continue
|
|
871
|
+
const { group, failures: unwritable } = offshoreGroup(spec, keys)
|
|
872
|
+
for (const failure of unwritable) failures.push(failure)
|
|
873
|
+
if (group !== undefined) groups.push(group)
|
|
874
|
+
}
|
|
486
875
|
|
|
487
876
|
const budgetMs = refreshBudgetMs ?? refreshBudgetFor(batch.length)
|
|
488
877
|
const startedAt = now()
|
|
@@ -504,18 +893,21 @@ export function createWatchlistService(options = {}) {
|
|
|
504
893
|
|
|
505
894
|
// 按**组序**归并,不按完成序:回包里的 failures 顺序是断言与面板读法的依赖,
|
|
506
895
|
// 并发不能把它变成"谁先回来谁在前"。
|
|
507
|
-
const failures = []
|
|
508
896
|
const quotes = new Map()
|
|
509
|
-
|
|
897
|
+
const problems = []
|
|
898
|
+
let failedGroups = 0
|
|
510
899
|
for (let index = 0; index < groups.length; index += 1) {
|
|
511
900
|
const result = results[index]
|
|
512
901
|
if (result === undefined) {
|
|
513
|
-
for (const thscode of groups[index].
|
|
902
|
+
for (const thscode of groups[index].keys) failures.push({ thscode, code: 'refresh_timeout' })
|
|
514
903
|
continue
|
|
515
904
|
}
|
|
516
905
|
for (const [thscode, quote] of result.byCode) quotes.set(thscode, quote)
|
|
517
906
|
for (const failure of result.failures) failures.push(failure)
|
|
518
|
-
if (result.batchError !== undefined)
|
|
907
|
+
if (result.batchError !== undefined) {
|
|
908
|
+
failedGroups += 1
|
|
909
|
+
problems.push(result.batchError)
|
|
910
|
+
}
|
|
519
911
|
}
|
|
520
912
|
|
|
521
913
|
// 写回整段进串行队列,并且**逐键重读当前记录**,只换 quote 那一格:
|
|
@@ -535,7 +927,13 @@ export function createWatchlistService(options = {}) {
|
|
|
535
927
|
// 一条都没落地就不报"刷新于此刻":页脚那个时间讲的是最后一次**真取到数**的时刻,
|
|
536
928
|
// 跟着一次全失败的点击往前走,就成了每一行都陈旧、只有页脚是新的(客户端据此保留上一个值)。
|
|
537
929
|
const result = { ok: true, items: written.items, refreshed_at: written.count > 0 ? now() : null, failures }
|
|
538
|
-
|
|
930
|
+
// ⛔ `scope` 的语义纪律:**只有整块一次都没成功**(每个分组都报了批级失败)才允许 `all`,
|
|
931
|
+
// 面板据此决定要不要占用居中位。把"半边失败"说成"服务不可用"就是面板对用户说谎。
|
|
932
|
+
if (problems.length > 0) {
|
|
933
|
+
const worst = worstProblem(problems)
|
|
934
|
+
const scope = failedGroups === groups.length && groups.length > 0 ? 'all' : worst.scope
|
|
935
|
+
result.error = { code: worst.code, message: messageFor(worst.code, scope), scope }
|
|
936
|
+
}
|
|
539
937
|
return result
|
|
540
938
|
}
|
|
541
939
|
|
|
@@ -554,16 +952,31 @@ export function createWatchlistService(options = {}) {
|
|
|
554
952
|
}
|
|
555
953
|
}
|
|
556
954
|
|
|
557
|
-
/**
|
|
558
|
-
function batchLevelCode(error) {
|
|
559
|
-
const code = watchlistErrorCode(error)
|
|
560
|
-
return
|
|
955
|
+
/** 只有会把**整批**都打挂的失败才升格成 batch error(限流 / 没配 key / 某一家服务不通)。 */
|
|
956
|
+
function batchLevelCode(error, source = 'fuyao') {
|
|
957
|
+
const code = watchlistErrorCode(error, { source })
|
|
958
|
+
return ['credential_missing', 'rate_limited', 'fuyao_unavailable', 'tencent_unavailable'].includes(code) ? code : undefined
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* 多路失败时挑一句最能指导动作的:密钥缺失是唯一"用户现在就能自己修"的,其次是限流,
|
|
963
|
+
* 最后才是服务不通。`scope` 跟着这一条走(谁最该被修,就报谁的射程)。
|
|
964
|
+
*/
|
|
965
|
+
const ERROR_CODE_PRIORITY = ['credential_missing', 'rate_limited', 'invalid_query', 'fuyao_unavailable', 'tencent_unavailable']
|
|
966
|
+
function worstProblem(problems) {
|
|
967
|
+
for (const code of ERROR_CODE_PRIORITY) {
|
|
968
|
+
const found = problems.find((problem) => problem.code === code)
|
|
969
|
+
if (found !== undefined) return found
|
|
970
|
+
}
|
|
971
|
+
return problems[0]
|
|
561
972
|
}
|
|
562
973
|
|
|
563
|
-
|
|
564
|
-
|
|
974
|
+
/** 错误码 → 用户看得见的一句话。`scope` 只用来决定"行情服务"要不要点名是哪一路(A 股 / 港美股)。 */
|
|
975
|
+
function messageFor(code, scope) {
|
|
976
|
+
if (code === 'credential_missing') return '未配置 Fuyao 密钥:在「插件」页的 Capital 模式卡片里填 API Key(只影响 A 股那一路,港美股照常用)'
|
|
565
977
|
if (code === 'rate_limited') return '行情服务限流,请稍后再试'
|
|
566
978
|
if (code === 'invalid_query') return '输入不被接受'
|
|
979
|
+
if (code === 'tencent_unavailable') return scope === 'a-share' ? '行情服务暂时不可用,请稍后再试' : '港美股行情暂时取不到,请稍后再试'
|
|
567
980
|
return '行情服务暂时不可用,请稍后再试'
|
|
568
981
|
}
|
|
569
982
|
|
|
@@ -606,6 +1019,7 @@ const FAILURE_STATUS = {
|
|
|
606
1019
|
credential_missing: 503,
|
|
607
1020
|
rate_limited: 429,
|
|
608
1021
|
fuyao_unavailable: 502,
|
|
1022
|
+
tencent_unavailable: 502,
|
|
609
1023
|
store_unavailable: 503,
|
|
610
1024
|
}
|
|
611
1025
|
|