@mzzsfy/dsh-stock-assistant 0.3.1 → 0.3.2
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/README.md +10 -7
- package/package.json +5 -2
- package/src/application.mjs +34 -15
- package/src/catalog.mjs +60 -31
- package/src/client.js +1288 -190
- package/src/collector.mjs +16 -6
- package/src/market-model.mjs +39 -11
- package/src/providers/eastmoney.mjs +51 -16
- package/src/providers/sina.mjs +121 -98
- package/src/providers/tencent.mjs +23 -14
- package/src/providers/tushare.mjs +1 -1
- package/src/providers/xueqiu.mjs +81 -0
- package/src/schemas.mjs +32 -9
- package/src/service.mjs +17 -6
- package/vendor/lightweight-charts.standalone.production.js +7 -0
- package//345/205/274/345/256/271/346/200/247/346/265/213/350/257/225.md +101 -2
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
DSH 全局股票工具。左侧“股票”入口打开独立主页面,无会话也可使用。切换、关闭 DSH 会话或临时打开其他工具,不会清除股票工具的搜索、详情、排序、滚动位置和设置草稿。
|
|
4
4
|
|
|
5
|
-
支持八个市场(沪/深 A 股、港股、美股 NYSE/NASDAQ
|
|
5
|
+
支持八个市场(沪/深 A 股、港股、美股 NYSE/NASDAQ、日经、韩证、伦敦):自选、名称/代码/拼音首字母搜索(多市场候选带币种徽标)、分组(置顶/各组/未分组常显段头,带组内涨跌统计与折叠;分组与排序正交,全局排序只决定组内顺序;悬浮窗勾选面板同源分区、可整组勾选)、K 线图表(分时/日K/周K/月K,MA/EMA/BOLL/BBI 可分别开关、成交量副图、昨收/价格标记/区间高低参考线,lightweight-charts 渲染)、悬浮盯盘(列表与图形双模式、只盯勾选的标的、可拖动、常驻在所有面板之上)、基础详情、别名、置顶、上下移动、涨跌排序、删除后撤销、手动刷新、暂停采集,以及数据源与能力路由设置(含"一键为已配置市场配置K线")。不包含交易、持仓、资讯或价格提醒。
|
|
6
6
|
|
|
7
7
|
## 数据结构与持久化
|
|
8
8
|
|
|
@@ -18,11 +18,13 @@ DSH 全局股票工具。左侧“股票”入口打开独立主页面,无会
|
|
|
18
18
|
|
|
19
19
|
具体介质路径由宿主的 storage backend 决定。本插件不向会话存储写数据。报价仅保留在 Host 内存中;搜索、详情、设置草稿等浏览状态由每个浏览器页面的运行时持有,刷新浏览器会重建这些临时状态。
|
|
20
20
|
|
|
21
|
+
**浏览器本地另存两项显示偏好**,不进 `control/root`:悬浮盯盘的开关/位置/勾选目标(`ssb-floating`)与 K 线辅助线开关(`ssb-guides`)。这两项都是纯客户端显示状态,跟着屏幕与个人习惯走,多设备之间没有同步价值;放进宿主 `preferences` 的话,每加一个开关都要改协议 schema 并重启宿主才能生效,收益不成比例。代价是它们不随账号同步、清空站点数据会丢。刷新间隔与涨跌配色仍留在宿主侧,因为那两项影响上游请求节奏与全局显示语义。业务数据(自选、成本、别名、分组、数据源与路由)一律只走宿主存储。
|
|
22
|
+
|
|
21
23
|
写操作在单一队列中执行,并携带版本及操作 ID。持久化成功后才发布;版本冲突保留用户输入并要求重新确认,写盘失败保留原数据。删除撤销有效期为8秒,恢复到当前列表原邻居附近,不回滚别页修改。损坏的权威记录会阻止激活,不会静默清空自选。
|
|
22
24
|
|
|
23
25
|
## 初次使用
|
|
24
26
|
|
|
25
|
-
全新安装即内置默认数据源:免凭据东财公开源(保守预算 60
|
|
27
|
+
全新安装即内置默认数据源:免凭据东财公开源(保守预算 60 次/分),八个市场的目录、搜索、报价、K 线四条能力全部配好,无需任何注册。打开后即可「检查所有来源能力」→ 保存 → 同步目录 → 用「浏览市场」翻页浏览全市场证券并直接加自选,或按代码/中文名/拼音首字母搜索。日经、韩证、伦敦三个市场由东财全球搜索与目录页覆盖,其成交额字段来源恒给 0,界面按缺失呈现而不是显示「0」。
|
|
26
28
|
|
|
27
29
|
- 顶栏「浏览市场」提供无目的浏览:按市场分页查看全部 A 股,行内一键加自选;目录未同步时页内有同步引导。
|
|
28
30
|
- 搜索在目录未就绪时不再死路:错误提示带「前往浏览市场」直达入口。
|
|
@@ -48,11 +50,12 @@ Tushare 的 `rt_k`、`stock_basic`、`trade_cal` 权限分别由账号决定。
|
|
|
48
50
|
| 适配器 | 能力 | 凭据 | 边界 |
|
|
49
51
|
| --- | --- | --- | --- |
|
|
50
52
|
| `tushare` | quotes / catalog / calendar | Host 环境变量 token | 唯一有正式权限表、配额文档与服务协议的渠道;三种能力可分开路由 |
|
|
51
|
-
| `tencent` |
|
|
52
|
-
| `sina` |
|
|
53
|
-
| `eastmoney` | quotes / catalog / search | 免凭据 | 同上;目录分页抓全量(单市场约2500
|
|
53
|
+
| `tencent` | quotes / kline | 免凭据 | 公开网页接口;非官方、无 SLA,协议随时可能变化 |
|
|
54
|
+
| `sina` | quotes / catalog | 免凭据 | 同上;价格恒三位小数,适配器裁剪到分;目录仅沪深 |
|
|
55
|
+
| `eastmoney` | quotes / catalog / search / kline | 免凭据 | 同上;目录分页抓全量(单市场约2500只,页频受限流退避约束),联想搜索支持中文/代码/首字母拼音;K 线含分时与日/周/月,分时时间戳统一按北京时间解析 |
|
|
56
|
+
| `xueqiu` | quotes | 免凭据(匿名 token) | 同上;可混装多市场,额外提供换手率与市值 |
|
|
54
57
|
|
|
55
|
-
**零注册全流程**:只添加 `eastmoney` 实例(设置页选适配器即标"免凭据",默认保守预算 60
|
|
58
|
+
**零注册全流程**:只添加 `eastmoney` 实例(设置页选适配器即标"免凭据",默认保守预算 60 次/分),八个市场各配 catalog/search/quotes/kline 四条路由,检查→保存→同步目录→搜索添加→看行情,全程不需要任何账户。日历能力仅 Tushare 提供;缺日历时插件按"未知交易状态"保守采集,功能不受阻。
|
|
56
59
|
|
|
57
60
|
混合路由:可把沪深行情分别路由到不同来源(沪市腾讯、深市东财、新浪备用等),目录/搜索用东财、日历用 Tushare——全部在设置页按市场/能力勾选。同一报价的现价、昨收、涨跌和时间始终来自同一适配器,不混拼字段。
|
|
58
61
|
|
|
@@ -66,7 +69,7 @@ Tushare 的 `rt_k`、`stock_basic`、`trade_cal` 权限分别由账号决定。
|
|
|
66
69
|
|
|
67
70
|
同额度组顺序执行,上游启动间隔至少1秒;最多两个额度组并发。总账号、能力子额度、每日额度和证券计费额度共同约束请求。网络错误指数退避;429遵守 Retry-After;AUTH/PERMISSION/BAD_PAYLOAD暂停该能力,检查或修改后恢复。已知休市/午休不持续采集,日历缺失按未知状态保守采集。
|
|
68
71
|
|
|
69
|
-
HTTP 路径前缀 `/api/stock-
|
|
72
|
+
HTTP 路径前缀 `/api/stock-assistant`:`snapshot/search/watchlist/preferences/provider-check/configuration/catalog-sync/route-selection/refresh`。写接口均为POST,读接口GET。普通请求上限10秒、单次上游8秒;目录同步30秒,多能力检查最多60秒。刷新直接等待批次并回快照,同价也有获取时间回执。周期启动下界同时考虑用户周期与来源额度。
|
|
70
73
|
|
|
71
74
|
通知复用 `@mzzsfy/dsh-common-tools` 的页面共享 WS,不创建股票专用 socket。失效通知与快照拉取分别合并;快照单飞、至少1秒启动间隔,失败不自循环重试。页面需求通过同一总线续租,不以 HTTP 心跳维持。
|
|
72
75
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-stock-assistant",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "股票单页面工具:独立于会话的自选、多平台数据路由与行情查看",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
"compatibility": {
|
|
23
23
|
"dshReleases": {
|
|
24
24
|
"0.2.1-rc.2": "compatible",
|
|
25
|
-
"0.2.1-rc.3": "compatible"
|
|
25
|
+
"0.2.1-rc.3": "compatible",
|
|
26
|
+
"0.2.1-rc.5": "compatible",
|
|
27
|
+
"0.2.2-rc.1": "compatible"
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
30
|
},
|
|
@@ -40,6 +42,7 @@
|
|
|
40
42
|
},
|
|
41
43
|
"files": [
|
|
42
44
|
"src",
|
|
45
|
+
"vendor",
|
|
43
46
|
"cordis.patch.yml",
|
|
44
47
|
"README.md",
|
|
45
48
|
"兼容性测试.md"
|
package/src/application.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { tushare } from './providers/tushare.mjs'
|
|
|
6
6
|
import { tencent } from './providers/tencent.mjs'
|
|
7
7
|
import { sina } from './providers/sina.mjs'
|
|
8
8
|
import { eastmoney } from './providers/eastmoney.mjs'
|
|
9
|
+
import { xueqiu } from './providers/xueqiu.mjs'
|
|
9
10
|
import { openState, digest } from './state.mjs'
|
|
10
11
|
import { parseConfiguration, preferencesSchema, defaultConfig, klineQuerySchema } from './schemas.mjs'
|
|
11
12
|
import { createWatchlistService } from './service.mjs'
|
|
@@ -18,8 +19,12 @@ import { createKlineCache } from './kline-cache.mjs'
|
|
|
18
19
|
import { fail, parseSecurityId, MARKET_META } from './market-model.mjs'
|
|
19
20
|
const WINDOW = 5 * 60 * 1000
|
|
20
21
|
const US_SYMBOL_TTL = 24 * 60 * 60 * 1000
|
|
21
|
-
|
|
22
|
+
const US_SYMBOL_LIMIT = 500
|
|
23
|
+
const PROBE_LIMIT = 512
|
|
24
|
+
export async function createApplication({ storageDomain, providers = [tushare, tencent, sina, eastmoney, xueqiu], publish = () => {}, env = process.env, fetch = globalThis.fetch, now = Date.now, timers = globalThis, vendorAvailable = true }) {
|
|
22
25
|
const serverEpoch = randomUUID(), checks = new Map()
|
|
26
|
+
// 目标粒度探针缓存: instanceId:capability:market → {status, until}; 配置保存校验按目标增量引用, 不随无关字段改动失效
|
|
27
|
+
const probeCache = new Map()
|
|
23
28
|
let snapshotSeq = 0, stopped = false, collector, catalog, demand
|
|
24
29
|
const usSymbols = new Map()
|
|
25
30
|
const touched = () => snapshotSeq++
|
|
@@ -46,7 +51,9 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
46
51
|
const wanted = demand.active(), opening = wanted && !hadDemand; hadDemand = wanted
|
|
47
52
|
if (wanted) {
|
|
48
53
|
ensureCaches()
|
|
49
|
-
|
|
54
|
+
// 缓存新鲜时进入面板不重复打上游(限流主因); 过期才拉取, 定时器照常量节奏维持
|
|
55
|
+
const freshFor = (getControl().preferences?.refreshSeconds ?? 15) * 1000
|
|
56
|
+
if (opening && !collector?.freshWithin(freshFor)) collector?.refresh().catch(() => {})
|
|
50
57
|
collector?.reconcile()
|
|
51
58
|
} else collector?.cancelIdle()
|
|
52
59
|
}, now, timers)
|
|
@@ -63,6 +70,8 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
63
70
|
const caps = z.array(z.enum(['quotes', 'catalog', 'search', 'calendar', 'kline'])).min(1).max(5).parse(input.capabilities)
|
|
64
71
|
const at = now()
|
|
65
72
|
for (const [id, check] of checks) if (check.until <= at) checks.delete(id)
|
|
73
|
+
// 过期目标直接淘汰: 键空间随实例/市场/能力组合有界, 但不留过期条目占位
|
|
74
|
+
for (const [key, entry] of probeCache) if (entry.until <= at) probeCache.delete(key)
|
|
66
75
|
if (checks.size >= 64) fail('CHECK_LIMIT', '检查请求过多,请稍后重试', 429)
|
|
67
76
|
const results = []
|
|
68
77
|
for (const route of config.routes.filter(r => caps.includes(r.capability))) for (const target of [route.primaryInstanceId, ...route.fallbackInstanceIds]) {
|
|
@@ -70,10 +79,15 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
70
79
|
registry.validate(instance, route)
|
|
71
80
|
const key = `${instance.id}:${route.capability}:${route.market}`
|
|
72
81
|
if (results.some(r => r.key === key)) continue
|
|
82
|
+
// 探针耗时就是这一条路由端到端拿数据的真实延迟, 用于把「能不能用」和「快不快」一起回给用户
|
|
83
|
+
const startedAt = now()
|
|
73
84
|
try {
|
|
74
85
|
const result = await executor.run(instance, route.capability, { market: route.market, instrument: route.instrument }, { probe: true, signal })
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
const status = result.status === 'ready' ? 'ready' : 'unavailable'
|
|
87
|
+
probeCache.set(key, { status, until: now() + WINDOW })
|
|
88
|
+
while (probeCache.size > PROBE_LIMIT) probeCache.delete(probeCache.keys().next().value)
|
|
89
|
+
results.push({ key, providerInstanceId: instance.id, capability: route.capability, market: route.market, status, ms: now() - startedAt })
|
|
90
|
+
} catch (e) { results.push({ key, providerInstanceId: instance.id, capability: route.capability, market: route.market, status: 'denied', ms: now() - startedAt, error: { code: e.code, message: e.message } }) }
|
|
77
91
|
}
|
|
78
92
|
const checkId = randomUUID()
|
|
79
93
|
checks.set(checkId, { hash: digest(config), until: now() + WINDOW, results })
|
|
@@ -85,11 +99,10 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
85
99
|
const config = parseConfiguration(operation.payload)
|
|
86
100
|
const replay = state.replay({ ...operation, action: 'configuration', payload: config })
|
|
87
101
|
if (replay) return replay
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
for (const route of config.routes)
|
|
91
|
-
const
|
|
92
|
-
if (!valid.some(c => c.results.some(r => r.providerInstanceId === target && r.capability === route.capability && r.market === route.market && r.status === 'ready'))) fail('CHECK_REQUIRED', '请先检查所有路由目标能力', 422)
|
|
102
|
+
// 保存不做连通性闸门: 探针是给人看的诊断信息, 不该把人挡在门外。契约只校验路由自身的合法性,
|
|
103
|
+
// 按能力挑源已经由前端按适配器能力过滤, 后端再校验一次目标是否具备该能力即可。
|
|
104
|
+
for (const route of config.routes) {
|
|
105
|
+
for (const target of [route.primaryInstanceId, ...route.fallbackInstanceIds]) registry.validate(config.providers.find(p => p.id === target), route)
|
|
93
106
|
}
|
|
94
107
|
const result = await state.mutate({ ...operation, action: 'configuration', payload: config }, current => {
|
|
95
108
|
if (JSON.stringify(current.providers) === JSON.stringify(config.providers) && JSON.stringify(current.routes) === JSON.stringify(config.routes)) return current
|
|
@@ -134,7 +147,10 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
134
147
|
if (phase === 'remote') {
|
|
135
148
|
for (const item of items) {
|
|
136
149
|
const row = item.raw && typeof item.raw === 'object' ? item.raw : null
|
|
137
|
-
if (row?.MktNum && /^10[567]$/.test(row.MktNum))
|
|
150
|
+
if (row?.MktNum && /^10[567]$/.test(row.MktNum)) {
|
|
151
|
+
usSymbols.set(`US:${row.Code}`, { mktNum: row.MktNum, jys: row.JYS || null, at: now() })
|
|
152
|
+
while (usSymbols.size > US_SYMBOL_LIMIT) usSymbols.delete(usSymbols.keys().next().value)
|
|
153
|
+
}
|
|
138
154
|
}
|
|
139
155
|
}
|
|
140
156
|
for (const [key, value] of usSymbols) if (now() - value.at > US_SYMBOL_TTL) usSymbols.delete(key)
|
|
@@ -144,15 +160,18 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
144
160
|
assertLive()
|
|
145
161
|
const parsed = klineQuerySchema.safeParse(input)
|
|
146
162
|
if (!parsed.success) fail('INVALID_PARAM', 'K线请求参数无效', 400)
|
|
147
|
-
const { securityId: sid, period, limit } = parsed.data
|
|
163
|
+
const { securityId: sid, period, limit, force } = parsed.data
|
|
148
164
|
let exchange, code
|
|
149
165
|
try { ({ exchange, code } = parseSecurityId(sid)) } catch { fail('INVALID_PARAM', '证券身份无效', 400) }
|
|
150
166
|
const config = state.get()
|
|
151
167
|
const { route, instance } = router.select('kline', exchange, 'equity')
|
|
152
168
|
const fallbacks = route.fallbackInstanceIds.map(id => config.providers.find(p => p.id === id && p.enabled !== false)).filter(Boolean)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
// force=1 跳过缓存读(详情页手动刷新);缓存写入仍按正常策略,失败回源不额外惩罚
|
|
170
|
+
if (force !== '1') {
|
|
171
|
+
for (const cand of [instance, ...fallbacks]) {
|
|
172
|
+
const cached = await klineCache.get({ providerInstanceId: cand.id, market: exchange, period, code }, config.routingRevision)
|
|
173
|
+
if (cached) return { ...cached.payload, source: cand.id, revision: config.revision }
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
const result = await runWithFailover(route, instance, fallbacks, { securityId: sid, market: exchange, code, period, adjust: 'qfq', limit }, signal)
|
|
158
177
|
if (period !== 'trend') await klineCache.put({ providerInstanceId: result.providerInstanceId, market: exchange, period, code }, result, config.routingRevision)
|
|
@@ -171,7 +190,7 @@ export async function createApplication({ storageDomain, providers = [tushare, t
|
|
|
171
190
|
if (route.instance) {
|
|
172
191
|
const result = await executor.run(route.instance, 'quotes', { securities: [{ id: index.id, exchange: index.exchange, code: index.code }], routingRevision: state.get().routingRevision }, { signal: AbortSignal.timeout(5 * 1000) })
|
|
173
192
|
const quote = result.items?.find(q => q.securityId === index.id)
|
|
174
|
-
if (quote && quote.last !== null) { deriveDelay(quote, now); overview = { name: index.name, last: quote.last, change: quote.change, changePct: quote.changePct, quoteAt: quote.quoteAt, delayKind: quote.delayKind, delaySeconds: quote.delaySeconds, source: quote.providerInstanceId } }
|
|
193
|
+
if (quote && quote.last !== null) { deriveDelay(quote, now, quote.sessionState === 'trading'); overview = { name: index.name, last: quote.last, change: quote.change, changePct: quote.changePct, quoteAt: quote.quoteAt, delayKind: quote.delayKind, delaySeconds: quote.delaySeconds, source: quote.providerInstanceId } }
|
|
175
194
|
}
|
|
176
195
|
} catch {}
|
|
177
196
|
return { ...await catalog.browse(input), overview, catalogs: catalog.status(), routingRevision: state.get().routingRevision }
|
package/src/catalog.mjs
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { securitySchema } from './schemas.mjs'
|
|
2
|
-
import { fail } from './market-model.mjs'
|
|
2
|
+
import { fail, sessionStateOf } from './market-model.mjs'
|
|
3
3
|
const DAY = 24 * 60 * 60 * 1000
|
|
4
|
+
const SEARCH_TTL = 60 * 1000
|
|
5
|
+
const SEARCH_CACHE_LIMIT = 64
|
|
6
|
+
const SEARCH_FLIGHT_LIMIT = 32
|
|
7
|
+
const CANDIDATE_TTL = 5 * 60 * 1000
|
|
8
|
+
const CANDIDATE_LIMIT = 200
|
|
9
|
+
// 有界写入: Map 迭代序即插入序, 超限淘汰最早条目, 保证长跑内存不随查询次数增长
|
|
10
|
+
export function setBounded(map, key, value, limit) {
|
|
11
|
+
map.set(key, value)
|
|
12
|
+
while (map.size > limit) map.delete(map.keys().next().value)
|
|
13
|
+
return map
|
|
14
|
+
}
|
|
4
15
|
const localDate = ms => new Date(ms + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
|
5
16
|
// 实例id→其适配器 markets 集合(搜索市场面唯一事实源)
|
|
6
17
|
function adapterMarketsOf(config, registry) {
|
|
@@ -13,7 +24,7 @@ function adapterMarketsOf(config, registry) {
|
|
|
13
24
|
}
|
|
14
25
|
|
|
15
26
|
export function createCatalog({ state, getControl = state.get, router, executor, registry, now = Date.now, changed = () => {} }) {
|
|
16
|
-
const flights = new Map(), candidates = new Map()
|
|
27
|
+
const flights = new Map(), candidates = new Map(), searchCache = new Map(), searchFlights = new Map()
|
|
17
28
|
let stopped = false
|
|
18
29
|
const controllers = new Set()
|
|
19
30
|
function records(kind) {
|
|
@@ -32,7 +43,8 @@ export function createCatalog({ state, getControl = state.get, router, executor,
|
|
|
32
43
|
function search(q, limit = 20) {
|
|
33
44
|
const query = q.trim().toLowerCase()
|
|
34
45
|
if (!query) return []
|
|
35
|
-
|
|
46
|
+
// 代码大小写不敏感: 美股代码全大写, 逐字比较会让本地目录搜不到 aapl/apple
|
|
47
|
+
const score = s => { const code = s.code.toLowerCase(), name = s.officialName.toLowerCase(); return code === query ? 0 : name === query ? 1 : code.startsWith(query) ? 2 : name.includes(query) ? 3 : s.cnspell?.toLowerCase().startsWith(query) ? 4 : 99 }
|
|
36
48
|
return all().map(s => ({ s, rank: score(s) })).filter(r => r.rank < 99).sort((a, b) => a.rank - b.rank || a.s.id.localeCompare(b.s.id)).slice(0, limit).map(r => r.s)
|
|
37
49
|
}
|
|
38
50
|
return {
|
|
@@ -55,37 +67,53 @@ export function createCatalog({ state, getControl = state.get, router, executor,
|
|
|
55
67
|
},
|
|
56
68
|
status: () => records('catalog').map(c => ({ source: c.source, fetchedAt: c.fetchedAt, complete: c.payload.complete, stale: now() - c.fetchedAt > (c.kind === 'calendar' ? 7 * DAY : DAY) })),
|
|
57
69
|
async remote(q, signal) {
|
|
58
|
-
const config = structuredClone(getControl()),
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const config = structuredClone(getControl()), revision = config.routingRevision, key = `${revision}:${q.trim().toLowerCase()}`
|
|
71
|
+
// 结果缓存与在途合并: 联想接口按查询词去重, 退格重输同一词零远端开销
|
|
72
|
+
const cached = searchCache.get(key)
|
|
73
|
+
if (cached && cached.until > now()) return cached.items
|
|
74
|
+
const inflight = searchFlights.get(key)
|
|
75
|
+
if (inflight && !inflight.signal?.aborted) return inflight.job
|
|
76
|
+
const job = (async () => {
|
|
77
|
+
const results = new Map(search(q).map(s => [s.id, s]))
|
|
78
|
+
// 搜索市场面=启用实例的适配器市场并集(suggest 天然全市场, 已启用市场语义跟随实例能力而非路由)
|
|
79
|
+
const adapterMarkets = adapterMarketsOf(config, registry)
|
|
80
|
+
const searchAdapters = new Set((registry?.descriptors() || []).filter(d => d.capabilities.includes('search')).map(d => d.adapterId))
|
|
81
|
+
const searchRoutes = new Map(config.routes.filter(r => r.capability === 'search').map(r => [r.market, r]))
|
|
82
|
+
// 实例→负责市场: 有 search 路由用路由实例, 无路由用支持 search 的启用实例直连(市场过滤在适配器端)
|
|
83
|
+
const assignments = new Map()
|
|
84
|
+
for (const market of new Set([...adapterMarkets.values()].flatMap(set => [...set]))) {
|
|
85
|
+
const instance = searchRoutes.has(market) ? router.select('search', market).instance
|
|
86
|
+
: config.providers.find(p => p.enabled && searchAdapters.has(p.adapterId) && adapterMarkets.get(p.id)?.has(market))
|
|
87
|
+
|| config.providers.find(p => p.enabled && searchAdapters.has(p.adapterId))
|
|
72
88
|
if (!instance) continue
|
|
89
|
+
const entry = assignments.get(instance.id) || { instance, markets: new Set() }
|
|
90
|
+
entry.markets.add(market); assignments.set(instance.id, entry)
|
|
73
91
|
}
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
92
|
+
// 一个实例只请求一次: 联想端点为全市场端点, 按市场循环是重复请求同一份数据
|
|
93
|
+
const batches = await Promise.allSettled([...assignments.values()].map(async ({ instance, markets }) => ({ markets, batch: await executor.run(instance, 'search', { q, limit: 20, market: null, routingRevision: revision }, { signal }) })))
|
|
94
|
+
for (const settled of batches) {
|
|
95
|
+
// 单来源限速不终止整个搜索: 其余实例候选照常下发
|
|
96
|
+
if (settled.status === 'rejected') { if (settled.reason?.code === 'RATE_LIMIT') continue; throw settled.reason }
|
|
97
|
+
const { markets, batch } = settled.value
|
|
98
|
+
if (stopped || getControl().routingRevision !== revision) fail('ROUTE_CONFLICT', '目录路由已变化', 409)
|
|
99
|
+
for (const s of batch.items) {
|
|
100
|
+
if (!securitySchema.safeParse(s).success) fail('BAD_PAYLOAD', '来源返回不受支持的证券身份', 502)
|
|
101
|
+
if (!markets.has(s.exchange)) continue
|
|
102
|
+
candidates.set(s.id, { security: s, until: now() + CANDIDATE_TTL, revision }); results.set(s.id, s)
|
|
103
|
+
}
|
|
82
104
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
105
|
+
for (const [id, c] of candidates) if (c.until <= now()) candidates.delete(id)
|
|
106
|
+
while (candidates.size > CANDIDATE_LIMIT) candidates.delete(candidates.keys().next().value)
|
|
107
|
+
if (!config.routes.some(r => r.capability === 'search') && !records('catalog').length) fail('CATALOG_NOT_READY', '目录未就绪,请先在设置中配置目录来源或搜索来源并同步', 422)
|
|
108
|
+
const items = [...results.values()].slice(0, 20)
|
|
109
|
+
setBounded(searchCache, key, { items, until: now() + SEARCH_TTL }, SEARCH_CACHE_LIMIT)
|
|
110
|
+
return items
|
|
111
|
+
})()
|
|
112
|
+
setBounded(searchFlights, key, { job, signal }, SEARCH_FLIGHT_LIMIT)
|
|
113
|
+
try { return await job } finally { if (searchFlights.get(key)?.job === job) searchFlights.delete(key) }
|
|
88
114
|
},
|
|
115
|
+
// 内存占用可观测: 长跑核对缓存不越界
|
|
116
|
+
stats: () => ({ searchCache: searchCache.size, candidates: candidates.size, searchFlights: searchFlights.size, syncFlights: flights.size }),
|
|
89
117
|
async ensure() {
|
|
90
118
|
for (const route of getControl().routes.filter(r => ['catalog', 'calendar'].includes(r.capability))) {
|
|
91
119
|
if (getControl().providers.find(p => p.id === route.primaryInstanceId)?.budget.requestsPerMinute === null) continue
|
|
@@ -122,7 +150,8 @@ export function createCatalog({ state, getControl = state.get, router, executor,
|
|
|
122
150
|
},
|
|
123
151
|
marketState(market) {
|
|
124
152
|
const date = localDate(now()), day = records('calendar').filter(c => c.payload.market === market).flatMap(c => c.payload.items).find(d => d.date === date)
|
|
125
|
-
|
|
153
|
+
// 没同步过日历就按市场自身的时段判断, 别让八市场统一显示「未知」; 有日历数据时以日历(含节假日)为准
|
|
154
|
+
if (!day || typeof day.isOpen !== 'boolean') return { state: sessionStateOf(market, now()) }
|
|
126
155
|
const local = new Date(now() + 8 * 60 * 60 * 1000), minutes = local.getUTCHours() * 60 + local.getUTCMinutes()
|
|
127
156
|
const at = minute => Date.parse(`${date}T00:00:00+08:00`) + minute * 60 * 1000
|
|
128
157
|
if (!day.isOpen) return { state: 'closed', nextAt: at(24 * 60 + 9 * 60 + 15) }
|