@mzzsfy/dsh-stock-assistant 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,100 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @mzzsfy/dsh-stock-assistant
2
+
3
+ DSH 全局股票工具。左侧“股票”入口打开独立主页面,无会话也可使用。切换、关闭 DSH 会话或临时打开其他工具,不会清除股票工具的搜索、详情、排序、滚动位置和设置草稿。
4
+
5
+ 支持八个市场(沪/深 A 股、港股、美股 NYSE/NASDAQ、日经、韩证、伦敦):自选、名称/代码/拼音首字母搜索(多市场候选带币种徽标)、分组(置顶/各组/未分组常显段头,带组内涨跌统计与折叠;分组与排序正交,全局排序只决定组内顺序;悬浮窗勾选面板同源分区、可整组勾选)、K 线图表(分时/日K/周K/月K,MA/EMA/BOLL/BBI 可分别开关、成交量副图、昨收/价格标记/区间高低参考线,lightweight-charts 渲染)、悬浮盯盘(列表与图形双模式、只盯勾选的标的、可拖动、常驻在所有面板之上)、基础详情、别名、置顶、上下移动、涨跌排序、删除后撤销、手动刷新、暂停采集,以及数据源与能力路由设置(含"一键为已配置市场配置K线")。不包含交易、持仓、资讯或价格提醒。
6
+
7
+ ## 数据结构与持久化
8
+
9
+ **不使用 DSH 默认设置系统。** 本插件不声明 `Config`,不调用 `settings` 或 `configEditor`。配置界面位于股票工具自己的“设置”视图中。
10
+
11
+ 通过宿主 `storageDomain` 使用专用 `stock_assistant` 数据域(2026-10-30 前为 `stock_sidebar`,随包改名一并更名;生产 profile 未挂载过旧域,无迁移),version=1、layout=single:
12
+
13
+ | 表 / 记录 | 内容 |
14
+ | --- | --- |
15
+ | `control/root` | 自选证券、手动顺序、置顶、别名、显示偏好、Provider 实例、能力路由、控制版本、近期幂等操作结果 |
16
+ | `cache/catalog:<instance>:<market>` | 可重建的标准证券目录、完整性、来源和获取时间 |
17
+ | `cache/calendar:<instance>:<market>` | 可重建的交易日历、日期范围和来源 |
18
+
19
+ 具体介质路径由宿主的 storage backend 决定。本插件不向会话存储写数据。报价仅保留在 Host 内存中;搜索、详情、设置草稿等浏览状态由每个浏览器页面的运行时持有,刷新浏览器会重建这些临时状态。
20
+
21
+ **浏览器本地另存两项显示偏好**,不进 `control/root`:悬浮盯盘的开关/位置/勾选目标(`ssb-floating`)与 K 线辅助线开关(`ssb-guides`)。这两项都是纯客户端显示状态,跟着屏幕与个人习惯走,多设备之间没有同步价值;放进宿主 `preferences` 的话,每加一个开关都要改协议 schema 并重启宿主才能生效,收益不成比例。代价是它们不随账号同步、清空站点数据会丢。刷新间隔与涨跌配色仍留在宿主侧,因为那两项影响上游请求节奏与全局显示语义。业务数据(自选、成本、别名、分组、数据源与路由)一律只走宿主存储。
22
+
23
+ 写操作在单一队列中执行,并携带版本及操作 ID。持久化成功后才发布;版本冲突保留用户输入并要求重新确认,写盘失败保留原数据。删除撤销有效期为8秒,恢复到当前列表原邻居附近,不回滚别页修改。损坏的权威记录会阻止激活,不会静默清空自选。
24
+
25
+ ## 初次使用
26
+
27
+ 全新安装即内置默认数据源:免凭据东财公开源(保守预算 60 次/分),八个市场的目录、搜索、报价、K 线四条能力全部配好,无需任何注册。打开后即可「检查所有来源能力」→ 保存 → 同步目录 → 用「浏览市场」翻页浏览全市场证券并直接加自选,或按代码/中文名/拼音首字母搜索。日经、韩证、伦敦三个市场由东财全球搜索与目录页覆盖,其成交额字段来源恒给 0,界面按缺失呈现而不是显示「0」。
28
+
29
+ - 顶栏「浏览市场」提供无目的浏览:按市场分页查看全部 A 股,行内一键加自选;目录未同步时页内有同步引导。
30
+ - 搜索在目录未就绪时不再死路:错误提示带「前往浏览市场」直达入口。
31
+ - 设置页在配置被清空时提供「启用默认数据源」一键恢复(与初始安装同一配置来源)。
32
+
33
+ 需要目录/日历(搜索、日历休市调度)或更正式的数据合同(权限表、配额文档、服务协议)时走 Tushare:
34
+
35
+ 1. 在 **启动 DSH 的 Host 进程环境**中配置 `DSH_STOCK_TUSHARE_TOKEN`。浏览器仅编辑变量名,不保存或回显 token。修改启动环境后需让新进程读取;仅修改引用名不会改变现有进程环境。
36
+ 2. 打开“股票 → 设置”,添加数据源实例,填写账号实际允许的请求预算。同一账号的实例使用同一“共享额度组”。新实例额度默认为未知;未填写时只允许每组最多每10秒一次主动探针,实际目录同步与报价采集均禁用,不假设任何免费套餐额度。
37
+ 3. 为上海/深圳分别选择证券目录、报价、交易日历来源。可以只启用自己需要的市场和能力。
38
+ 4. 点击“检查所有来源能力”。所有被引用能力检查就绪后保存。检查消耗该来源请求额度,检查通过不代表任何第三方展示许可。
39
+ 5. 同步需要的市场目录和日历,搜索证券、选择候选并确认添加。
40
+ 6. 列表或详情可见时自动采集;进入设置、搜索、隐藏页面或暂停时停止自动采集。暂停期间手动刷新可更新当前静态快照。
41
+
42
+ 设置页按「① 数据源 → ② 市场与能力 → ③ 显示偏好」递进组织;每个数据源实例卡内可见适配器一句话说明、「接口反代」端点覆盖(自建反向代理/镜像时替换地址,留空用官方默认;东财五个端点、腾讯两个端点)、高级预算折叠组与「运行状态」最近请求轨迹(环形 20 条:时间/能力/状态/耗时,用于确认上游是否可达)。实例还支持可选 HTTP 代理出口(`http(s)://[user:pass@]host:port`,经 undici ProxyAgent;一般无需配置)。列表页顶部常驻数据源概览横幅:启用数/最近请求失败实例一目了然,「管理」直达设置。
43
+
44
+ Tushare 的 `rt_k`、`stock_basic`、`trade_cal` 权限分别由账号决定。目录与日历权限不足不等于“市场为空”。`rt_k` 使用 HTTPS;本插件不使用 HTTP 降级,也不通过浏览器直接访问来源。具体能力检查以实际账号结果为准。
45
+
46
+ ## 数据源适配器
47
+
48
+ 生产内置四个适配器,各自独立文件(`src/providers/`),互不依赖:
49
+
50
+ | 适配器 | 能力 | 凭据 | 边界 |
51
+ | --- | --- | --- | --- |
52
+ | `tushare` | quotes / catalog / calendar | Host 环境变量 token | 唯一有正式权限表、配额文档与服务协议的渠道;三种能力可分开路由 |
53
+ | `tencent` | quotes / kline | 免凭据 | 公开网页接口;非官方、无 SLA,协议随时可能变化 |
54
+ | `sina` | quotes / catalog | 免凭据 | 同上;价格恒三位小数,适配器裁剪到分;目录仅沪深 |
55
+ | `eastmoney` | quotes / catalog / search / kline | 免凭据 | 同上;目录分页抓全量(单市场约2500只,页频受限流退避约束),联想搜索支持中文/代码/首字母拼音;K 线含分时与日/周/月,分时时间戳统一按北京时间解析 |
56
+ | `xueqiu` | quotes | 免凭据(匿名 token) | 同上;可混装多市场,额外提供换手率与市值 |
57
+
58
+ **零注册全流程**:只添加 `eastmoney` 实例(设置页选适配器即标"免凭据",默认保守预算 60 次/分),八个市场各配 catalog/search/quotes/kline 四条路由,检查→保存→同步目录→搜索添加→看行情,全程不需要任何账户。日历能力仅 Tushare 提供;缺日历时插件按"未知交易状态"保守采集,功能不受阻。
59
+
60
+ 混合路由:可把沪深行情分别路由到不同来源(沪市腾讯、深市东财、新浪备用等),目录/搜索用东财、日历用 Tushare——全部在设置页按市场/能力勾选。同一报价的现价、昨收、涨跌和时间始终来自同一适配器,不混拼字段。
61
+
62
+ 免凭据机制:实例 `credentialEnv=null` 时跳过 Host 环境变量检查,但 schema 强制其声明保守预算(防无界请求);凭据型适配器切换回档时自动恢复变量名输入框。
63
+
64
+ 新增平台步骤:在 `src/providers/` 实现标准合同(`descriptor.credential` 声明 `'env'|'none'` + 能力方法 + `probe`),在 `application.mjs` 的 registry 登记,测试放 `test/public-providers.test.mjs`。UI、存储、自选模型零改动。
65
+
66
+ 报价路由可选一个备用源,自动切换默认关闭。开启后,连续三轮完全由 NETWORK/TIMEOUT 失败才整条路由切换;权限错误、额度限制与逐证券缺失不触发。切换增加运行代次并丢弃旧请求,最短驻留10分钟,回主源必须手动检查。设置视图显示当前备用选择与回主源操作。
67
+
68
+ ## 调度与接口
69
+
70
+ 同额度组顺序执行,上游启动间隔至少1秒;最多两个额度组并发。总账号、能力子额度、每日额度和证券计费额度共同约束请求。网络错误指数退避;429遵守 Retry-After;AUTH/PERMISSION/BAD_PAYLOAD暂停该能力,检查或修改后恢复。已知休市/午休不持续采集,日历缺失按未知状态保守采集。
71
+
72
+ HTTP 路径前缀 `/api/stock-assistant`:`snapshot/search/watchlist/preferences/provider-check/configuration/catalog-sync/route-selection/refresh`。写接口均为POST,读接口GET。普通请求上限10秒、单次上游8秒;目录同步30秒,多能力检查最多60秒。刷新直接等待批次并回快照,同价也有获取时间回执。周期启动下界同时考虑用户周期与来源额度。
73
+
74
+ 通知复用 `@mzzsfy/dsh-common-tools` 的页面共享 WS,不创建股票专用 socket。失效通知与快照拉取分别合并;快照单飞、至少1秒启动间隔,失败不自循环重试。页面需求通过同一总线续租,不以 HTTP 心跳维持。
75
+
76
+ ## 安装与开发
77
+
78
+ 源码初始版本0.1.0,尚未发布。仓库开发挂载遵循根目录 `scripts/dev-link.mjs dsh-stock-assistant`;本轮实现未修改生产 profile,也未重启生产宿主。装载层新增插件通常需要操作者在合适时机重新启动宿主。
79
+
80
+ 要求 Node>=22,宿主已有 `webServer`(包括upgrade)、`storageDomain`、有效 backend;client 需要 `slots/locale/layout`。缺服务干净禁用。公共 client 依赖由 `dsh-session-manager` 的唯一占位项承载,本包只声明 dependency/external,不插第二条占位。Host/client使用同代 common-tools 0.3.x。
81
+
82
+ ```sh
83
+ npm install --omit=peer
84
+ npm test
85
+ ```
86
+
87
+ 生产 client 是原生 ModuleLoader 单入口,没有生产打包步骤、独立状态库或 React 副本。devDependencies 的 React/ReactDOM/esbuild 仅用于实际组件测试和隔离验收夹具,不进入生产运行路径。
88
+
89
+ 有头浏览器夹具可运行 `node test/browser-fixture.mjs`,输出随机本地端口。它使用固定行情、内存存储、真实client组件和真实公共快照调度器,测试传输替为夹具事件桥;不能将其作为真实Tushare账户或宿主WS验收证据。使用后终止该进程。
90
+
91
+ ## DSH 版本兼容
92
+
93
+ | 版本 | 状态 | 证据边界 |
94
+ | --- | --- | --- |
95
+ | 0.2.1-rc.2 | compatible | 隔离真宿主:导航入口、main页面、样式、快照路由、WS ready→invalidate、重载后偏好保留 |
96
+ | 0.2.1-rc.3 | compatible | 同款隔离验证 + 无凭据AUTH检查拒绝与保存守卫;安装需allowBuilds清单 |
97
+ | 其余版本 | unknown | 未实测不声明 |
98
+
99
+ 逐项证据和局限见[兼容性测试](./兼容性测试.md)。实现规格与研究分别见[开发文档](../../docs/开发-股票侧边栏插件.md)、[调研文档](../../docs/调研-股票侧边栏插件.md)。
100
+
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: stock-assistant
3
+ name: '@mzzsfy/dsh-stock-assistant'
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-stock-assistant",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "股票单页面工具:独立于会话的自选、多平台数据路由与行情查看",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./src/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "platform": "web",
18
+ "external": [
19
+ "@mzzsfy/dsh-common-tools/client"
20
+ ]
21
+ },
22
+ "compatibility": {
23
+ "dshReleases": {
24
+ "0.2.1-rc.2": "compatible",
25
+ "0.2.1-rc.3": "compatible",
26
+ "0.2.1-rc.5": "compatible",
27
+ "0.2.2-rc.1": "compatible"
28
+ }
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@mzzsfy/dsh-common-tools": "^0.3.0",
33
+ "lightweight-charts": "^5.2.1",
34
+ "zod": "^4.6.6"
35
+ },
36
+ "scripts": {
37
+ "test": "node --test \"test/*.test.mjs\"",
38
+ "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
39
+ },
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "files": [
44
+ "src",
45
+ "vendor",
46
+ "cordis.patch.yml",
47
+ "README.md",
48
+ "兼容性测试.md"
49
+ ],
50
+ "license": "MIT",
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/mzzsfy/dsh-plugin.git"
54
+ },
55
+ "devDependencies": {
56
+ "esbuild": "^0.28.2",
57
+ "react": "^19.3.1",
58
+ "react-dom": "^19.3.1"
59
+ },
60
+ "publishConfig": {
61
+ "access": "public",
62
+ "registry": "https://registry.npmjs.org"
63
+ }
64
+ }
@@ -0,0 +1,231 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { z } from 'zod'
3
+ import { createRegistry } from './provider-registry.mjs'
4
+ import { createRouter } from './provider-router.mjs'
5
+ import { tushare } from './providers/tushare.mjs'
6
+ import { tencent } from './providers/tencent.mjs'
7
+ import { sina } from './providers/sina.mjs'
8
+ import { eastmoney } from './providers/eastmoney.mjs'
9
+ import { xueqiu } from './providers/xueqiu.mjs'
10
+ import { openState, digest } from './state.mjs'
11
+ import { parseConfiguration, preferencesSchema, defaultConfig, klineQuerySchema } from './schemas.mjs'
12
+ import { createWatchlistService } from './service.mjs'
13
+ import { createBudget } from './budget.mjs'
14
+ import { createExecutor } from './executor.mjs'
15
+ import { createCollector, deriveDelay } from './collector.mjs'
16
+ import { createCatalog } from './catalog.mjs'
17
+ import { createDemand } from './demand.mjs'
18
+ import { createKlineCache } from './kline-cache.mjs'
19
+ import { fail, parseSecurityId, MARKET_META } from './market-model.mjs'
20
+ const WINDOW = 5 * 60 * 1000
21
+ const US_SYMBOL_TTL = 24 * 60 * 60 * 1000
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 }) {
25
+ const serverEpoch = randomUUID(), checks = new Map()
26
+ // 目标粒度探针缓存: instanceId:capability:market → {status, until}; 配置保存校验按目标增量引用, 不随无关字段改动失效
27
+ const probeCache = new Map()
28
+ let snapshotSeq = 0, stopped = false, collector, catalog, demand
29
+ const usSymbols = new Map()
30
+ const touched = () => snapshotSeq++
31
+ const changed = () => { touched(); publish({ type: 'invalidate', serverEpoch, snapshotSeq, scopes: ['snapshot'] }) }
32
+ const state = await openState(storageDomain, changed, now)
33
+ const registry = createRegistry(providers), budget = createBudget(now)
34
+ // 代理 dispatcher 工厂: undici ProxyAgent(实例级上游出口), 包不可用时该实例代理静默降级直连
35
+ let proxyFactory = null
36
+ try { const undici = await import('undici'); proxyFactory = url => new undici.ProxyAgent(url) } catch {}
37
+ const executor = createExecutor({ registry, budget, env, fetch, now, timers, usSymbols, proxyFactory })
38
+ const getControl = state.get
39
+ const router = createRouter(getControl, now, () => collector?.reset())
40
+ const klineCache = createKlineCache({ cacheTable: state.cache, now })
41
+ catalog = createCatalog({ state, getControl, router, executor, registry, now, changed })
42
+ let ensuring = null, lastEnsure = -Infinity, ensureError = null
43
+ const ensureCaches = () => {
44
+ // 面板活跃(demand)时让路: 目录后台同步占满并发槽会拖死交互请求(浏览504实测), 仅在面板空闲时补目录
45
+ if (ensuring || now() - lastEnsure < 60 * 1000 || demand.active()) return
46
+ lastEnsure = now()
47
+ ensuring = catalog.ensure().catch(e => { ensureError = { message: e.message, at: now() }; changed() }).finally(() => { ensuring = null; collector?.reconcile() })
48
+ }
49
+ let hadDemand = false
50
+ demand = createDemand(() => {
51
+ const wanted = demand.active(), opening = wanted && !hadDemand; hadDemand = wanted
52
+ if (wanted) {
53
+ ensureCaches()
54
+ // 缓存新鲜时进入面板不重复打上游(限流主因); 过期才拉取, 定时器照常量节奏维持
55
+ const freshFor = (getControl().preferences?.refreshSeconds ?? 15) * 1000
56
+ if (opening && !collector?.freshWithin(freshFor)) collector?.refresh().catch(() => {})
57
+ collector?.reconcile()
58
+ } else collector?.cancelIdle()
59
+ }, now, timers)
60
+ collector = createCollector({ getControl, router, registry, executor, resolveSecurity: catalog.resolve, changed, touched, now, timers, active: demand.active, calendarState: catalog.marketState })
61
+ const watchlist = createWatchlistService(state, catalog.resolve, now)
62
+ const snapshot = () => {
63
+ const control = state.get(); delete control.operations
64
+ return { serverEpoch, snapshotSeq, routingRevision: control.routingRevision, control, quotes: collector.entries(), routeSelections: router.selections(), catalogs: catalog.status(), adapters: registry.descriptors(), suggested: defaultConfig(), ensureError, vendorAvailable, providerStatus: control.providers.map(p => ({ id: p.id, credentialPresent: p.credentialEnv === null || !!env[p.credentialEnv], proxy: !!p.proxy, endpointsCovered: Object.keys(p.endpoints || {}).length, recentRequests: executor.recentRequests(p.id) })), klineReady: Object.fromEntries(control.routes.filter(r => r.capability === 'kline').map(r => [r.market, true])), markets: Object.fromEntries(Object.entries(MARKET_META).map(([market, meta]) => [market, { currency: meta.currency, verified: meta.verifiedByDefault }])) }
65
+ }
66
+ function assertLive() { if (stopped) fail('STOPPED', '股票工具已停止', 503) }
67
+ async function providerCheck(input, signal) {
68
+ assertLive()
69
+ const config = parseConfiguration({ providers: input.providers, routes: input.routes })
70
+ const caps = z.array(z.enum(['quotes', 'catalog', 'search', 'calendar', 'kline'])).min(1).max(5).parse(input.capabilities)
71
+ const at = now()
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)
75
+ if (checks.size >= 64) fail('CHECK_LIMIT', '检查请求过多,请稍后重试', 429)
76
+ const results = []
77
+ for (const route of config.routes.filter(r => caps.includes(r.capability))) for (const target of [route.primaryInstanceId, ...route.fallbackInstanceIds]) {
78
+ const instance = config.providers.find(p => p.id === target)
79
+ registry.validate(instance, route)
80
+ const key = `${instance.id}:${route.capability}:${route.market}`
81
+ if (results.some(r => r.key === key)) continue
82
+ // 探针耗时就是这一条路由端到端拿数据的真实延迟, 用于把「能不能用」和「快不快」一起回给用户
83
+ const startedAt = now()
84
+ try {
85
+ const result = await executor.run(instance, route.capability, { market: route.market, instrument: route.instrument }, { probe: true, signal })
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 } }) }
91
+ }
92
+ const checkId = randomUUID()
93
+ checks.set(checkId, { hash: digest(config), until: now() + WINDOW, results })
94
+ if (digest(config) === digest({ providers: getControl().providers, routes: getControl().routes }) && results.length && results.every(r => r.status === 'ready')) collector.resume()
95
+ return { checkId, checks: results }
96
+ }
97
+ async function configuration(operation) {
98
+ assertLive()
99
+ const config = parseConfiguration(operation.payload)
100
+ const replay = state.replay({ ...operation, action: 'configuration', payload: config })
101
+ if (replay) return replay
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)
106
+ }
107
+ const result = await state.mutate({ ...operation, action: 'configuration', payload: config }, current => {
108
+ if (JSON.stringify(current.providers) === JSON.stringify(config.providers) && JSON.stringify(current.routes) === JSON.stringify(config.routes)) return current
109
+ return { ...current, ...config, routingRevision: current.routingRevision + 1 }
110
+ })
111
+ if (!result.unchanged) { router.reset(); collector.reset() }
112
+ return result
113
+ }
114
+
115
+ // 搜索候选项服务端补全市场元数据(单一事实源 MARKET_META),client 零映射表。
116
+ function decorateWithMarketMeta(items) {
117
+ for (const item of items) if (item?.exchange && !item.currency) {
118
+ const meta = MARKET_META[item.exchange]
119
+ if (meta) { item.currency = meta.currency; item.priceScale = meta.priceScale }
120
+ }
121
+ }
122
+ // kline 即时故障转移: 触发码 NETWORK/TIMEOUT/BAD_PAYLOAD 且有备源→逐备源各试一次;每次尝试后 recordRound 记账;RATE_LIMIT 不触发。
123
+ async function runWithFailover(route, primary, fallbacks, request, signal) {
124
+ const chain = [primary, ...fallbacks]
125
+ let lastError
126
+ for (const cand of chain) {
127
+ try {
128
+ return await executor.run(cand, 'kline', request, { signal })
129
+ } catch (e) {
130
+ lastError = e
131
+ router.recordRound(route.id, e)
132
+ if (!['NETWORK', 'TIMEOUT', 'BAD_PAYLOAD'].includes(e.code)) throw e
133
+ }
134
+ }
135
+ throw lastError
136
+ }
137
+
138
+ return {
139
+ snapshot, providerCheck, configuration,
140
+ demand(message) { assertLive(); demand.update(message) },
141
+ async search(q, phase = 'local', signal) {
142
+ assertLive()
143
+ if (typeof q !== 'string' || [...q].length > 100) fail('INVALID_QUERY', '搜索词过长')
144
+ const items = phase === 'remote' ? await catalog.remote(q, signal) : catalog.search(q)
145
+ decorateWithMarketMeta(items)
146
+ const routingRevision = state.get().routingRevision
147
+ if (phase === 'remote') {
148
+ for (const item of items) {
149
+ const row = item.raw && typeof item.raw === 'object' ? item.raw : null
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
+ }
154
+ }
155
+ }
156
+ for (const [key, value] of usSymbols) if (now() - value.at > US_SYMBOL_TTL) usSymbols.delete(key)
157
+ return { items, catalogs: catalog.status(), routingRevision }
158
+ },
159
+ async kline(input, signal) {
160
+ assertLive()
161
+ const parsed = klineQuerySchema.safeParse(input)
162
+ if (!parsed.success) fail('INVALID_PARAM', 'K线请求参数无效', 400)
163
+ const { securityId: sid, period, limit, force } = parsed.data
164
+ let exchange, code
165
+ try { ({ exchange, code } = parseSecurityId(sid)) } catch { fail('INVALID_PARAM', '证券身份无效', 400) }
166
+ const config = state.get()
167
+ const { route, instance } = router.select('kline', exchange, 'equity')
168
+ const fallbacks = route.fallbackInstanceIds.map(id => config.providers.find(p => p.id === id && p.enabled !== false)).filter(Boolean)
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
+ }
175
+ }
176
+ const result = await runWithFailover(route, instance, fallbacks, { securityId: sid, market: exchange, code, period, adjust: 'qfq', limit }, signal)
177
+ if (period !== 'trend') await klineCache.put({ providerInstanceId: result.providerInstanceId, market: exchange, period, code }, result, config.routingRevision)
178
+ return { ...result, source: result.providerInstanceId, revision: config.revision }
179
+ },
180
+ async browse(input) {
181
+ assertLive()
182
+ if (!MARKET_META[input.market]) fail('INVALID_REQUEST', '市场参数无效')
183
+ // 市场概览: 代表指数不入目录/自选, 走 quotes 管道直拉; 失败静默为 null 不阻断目录浏览
184
+ const INDEX = { XSHG: { id: 'XSHG:index:000001', exchange: 'XSHG', code: '000001', name: '上证指数' }, XSHE: { id: 'XSHE:index:399001', exchange: 'XSHE', code: '399001', name: '深证成指' }, XHKG: { id: 'XHKG:index:HSI', exchange: 'XHKG', code: 'HSI', name: '恒生指数' }, XNAS: { id: 'XNAS:index:NDX', exchange: 'XNAS', code: 'NDX', name: '纳斯达克100' }, XNYS: { id: 'XNYS:index:DJI', exchange: 'XNYS', code: 'DJI', name: '道琼斯' }, XJPX: { id: 'XJPX:index:N225', exchange: 'XJPX', code: 'N225', name: '日经225' }, XKRX: { id: 'XKRX:index:KOSPI', exchange: 'XKRX', code: 'KOSPI', name: '韩国综指' }, XLON: { id: 'XLON:index:FTSE', exchange: 'XLON', code: 'FTSE', name: '富时100' } }
185
+ const index = INDEX[input.market]
186
+ // 概览指数是装饰性数据: 无路由或拉取失败(含5s短超时不与目录翻页争槽)都静默为 null, 不阻断目录浏览
187
+ let overview = null
188
+ try {
189
+ const route = router.select('quotes', input.market)
190
+ if (route.instance) {
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) })
192
+ const quote = result.items?.find(q => q.securityId === index.id)
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 } }
194
+ }
195
+ } catch {}
196
+ return { ...await catalog.browse(input), overview, catalogs: catalog.status(), routingRevision: state.get().routingRevision }
197
+ },
198
+ async sync(input) {
199
+ assertLive()
200
+ if (!MARKET_META[input.market] || input.expectedRoutingRevision !== state.get().routingRevision) fail('ROUTE_CONFLICT', '目录路由已变化', 409)
201
+ const result = await catalog.sync(input.market); collector.reconcile(); return result
202
+ },
203
+ async watchlist(operation) { assertLive(); const result = await watchlist.execute(operation); collector.reconcile(); return result },
204
+ async preferences(operation) {
205
+ assertLive()
206
+ const parsed = preferencesSchema.partial().safeParse(operation.payload)
207
+ if (!parsed.success) fail('INVALID_PREFERENCES', '显示偏好无效')
208
+ const result = await state.mutate({ ...operation, action: 'preferences' }, current => ({ ...current, preferences: { ...current.preferences, ...parsed.data } }))
209
+ collector.reconcile(); return result
210
+ },
211
+ async refresh(input = {}, signal) {
212
+ assertLive()
213
+ if (input.expectedRoutingRevision !== undefined && input.expectedRoutingRevision !== state.get().routingRevision) fail('ROUTE_CONFLICT', '路由配置已变化', 409)
214
+ if (input.securities && (!Array.isArray(input.securities) || input.securities.length > 100 || input.securities.some(s => typeof s === 'string' ? typeof s !== 'string' : !(typeof s === 'object' && typeof s.id === 'string' && typeof s.exchange === 'string' && typeof s.code === 'string')))) fail('INVALID_REFRESH', '刷新证券参数无效')
215
+ try { await collector.refresh(input.securities, { signal }) } catch (e) { e.snapshot = snapshot(); throw e }
216
+ return snapshot()
217
+ },
218
+ async routeSelection(input, signal) {
219
+ assertLive()
220
+ const config = state.get(), route = config.routes.find(r => r.id === input.routeId)
221
+ if (!route || input.target !== 'primary' || input.expectedRoutingRevision !== config.routingRevision) fail('ROUTE_CONFLICT', '路由配置已变化', 409)
222
+ const instance = config.providers.find(p => p.id === route.primaryInstanceId)
223
+ const selected = router.selections()[route.id]
224
+ if (selected.routeEpoch !== input.expectedRouteEpoch || selected.switchedAt !== null && now() - selected.switchedAt < 10 * 60 * 1000) fail('ROUTE_CONFLICT', '来源已变化或备用驻留期未结束', 409)
225
+ const checked = await executor.run(instance, 'quotes', { market: route.market }, { probe: true, signal })
226
+ if (checked.status !== 'ready' || config.routingRevision !== state.get().routingRevision) fail('CHECK_REQUIRED', '主源未就绪或路由已变化', 422)
227
+ router.switchPrimary(route.id, input.expectedRouteEpoch); return snapshot()
228
+ },
229
+ async close() { stopped = true; demand.dispose(); collector.dispose(); const cacheClosed = catalog.dispose(); executor.dispose(); await cacheClosed; await ensuring; await state.close() },
230
+ }
231
+ }
package/src/budget.mjs ADDED
@@ -0,0 +1,34 @@
1
+ import { fail } from './market-model.mjs'
2
+ const MINUTE = 60 * 1000
3
+ export function createBudget(now = Date.now) {
4
+ const groups = new Map()
5
+ return {
6
+ reserve(instance, capability, count = 1, periodic = false, probe = false) {
7
+ const key = instance.quotaGroup, budget = instance.budget
8
+ const day = new Date(now() + 8 * 60 * MINUTE).toISOString().slice(0, 10)
9
+ if (!groups.has(key)) groups.set(key, { day, dayCount: 0, rows: [], last: -Infinity, blocked: 0 })
10
+ const s = groups.get(key)
11
+ if (day !== s.day) { s.day = day; s.dayCount = 0 }
12
+ s.rows = s.rows.filter(r => now() - r.at < MINUTE)
13
+ const limited = wait => fail('RATE_LIMIT', '数据源额度受限,请稍后重试', 429, { retryAfterMs: Math.max(1000, wait), retryable: true })
14
+ if (budget.requestsPerMinute === null) {
15
+ if (!probe) fail('BUDGET_UNKNOWN', '账号额度未确认,请先在设置中填写实际预算', 422)
16
+ if (now() - s.last < 10 * 1000) limited(10 * 1000 - (now() - s.last))
17
+ if (s.blocked > now()) limited(s.blocked - now())
18
+ s.last = now(); s.rows.push({ at: now(), capability, count }); s.dayCount++; return
19
+ }
20
+ if (s.blocked > now()) limited(s.blocked - now())
21
+ if (now() - s.last < 1000) limited(1000 - (now() - s.last))
22
+ const fraction = periodic ? 0.9 : 1
23
+ const cap = Math.max(1, Math.floor(budget.requestsPerMinute * fraction))
24
+ const subcap = Math.max(1, Math.floor((budget.capabilityLimits[capability] || budget.requestsPerMinute) * fraction))
25
+ const sub = s.rows.filter(r => r.capability === capability)
26
+ if (s.rows.length >= cap) limited(s.rows[0].at + MINUTE - now())
27
+ if (sub.length >= subcap) limited(sub[0].at + MINUTE - now())
28
+ if (budget.dailyRequestLimit && s.dayCount >= budget.dailyRequestLimit) limited(24 * 60 * MINUTE - (now() + 8 * 60 * MINUTE) % (24 * 60 * MINUTE))
29
+ if (budget.billingUnit === 'symbol' && s.rows.reduce((sum, r) => sum + r.count, 0) + count > budget.symbolCreditsPerMinute) limited((s.rows[0]?.at ?? now()) + MINUTE - now())
30
+ s.rows.push({ at: now(), capability, count }); s.last = now(); s.dayCount++
31
+ },
32
+ block(instance, ms) { const s = groups.get(instance.quotaGroup); if (s) s.blocked = Math.max(s.blocked, now() + ms) },
33
+ }
34
+ }
@@ -0,0 +1,167 @@
1
+ import { securitySchema } from './schemas.mjs'
2
+ import { fail, sessionStateOf } from './market-model.mjs'
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
+ }
15
+ const localDate = ms => new Date(ms + 8 * 60 * 60 * 1000).toISOString().slice(0, 10)
16
+ // 实例id→其适配器 markets 集合(搜索市场面唯一事实源)
17
+ function adapterMarketsOf(config, registry) {
18
+ const map = new Map()
19
+ for (const p of config.providers.filter(p => p.enabled)) {
20
+ const adapter = (registry?.descriptors() || []).find(d => d.adapterId === p.adapterId)
21
+ map.set(p.id, new Set(adapter?.markets || []))
22
+ }
23
+ return map
24
+ }
25
+
26
+ export function createCatalog({ state, getControl = state.get, router, executor, registry, now = Date.now, changed = () => {} }) {
27
+ const flights = new Map(), candidates = new Map(), searchCache = new Map(), searchFlights = new Map()
28
+ let stopped = false
29
+ const controllers = new Set()
30
+ function records(kind) {
31
+ const config = getControl(), result = []
32
+ for (const [, cache] of state.cache.entries()) {
33
+ if (cache.kind !== kind || cache.payload?.routingRevision !== config.routingRevision || !Array.isArray(cache.payload?.items) || kind === 'catalog' && cache.payload.items.some(s => !securitySchema.safeParse(s).success) || kind === 'calendar' && cache.payload.items.some(d => !/^\d{4}-\d{2}-\d{2}$/.test(d.date) || typeof d.isOpen !== 'boolean')) continue
34
+ result.push(cache)
35
+ }
36
+ return result
37
+ }
38
+ function all() {
39
+ const map = new Map()
40
+ for (const cache of records('catalog')) for (const s of cache.payload.items) if (securitySchema.safeParse(s).success) map.set(s.id, s)
41
+ return [...map.values()]
42
+ }
43
+ function search(q, limit = 20) {
44
+ const query = q.trim().toLowerCase()
45
+ if (!query) return []
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 }
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)
49
+ }
50
+ return {
51
+ search,
52
+ async browse({ market, offset, limit }) {
53
+ // 缓存缺失不再报错甩给用户: 返回 syncing 态并触发后台同步(sync 内部 flights 去重), 同步后为空是合法状态
54
+ if (!records('catalog').some(c => c.payload.market === market)) {
55
+ this.sync(market, ['catalog']).catch(() => {})
56
+ return { syncing: true, total: 0, items: [] }
57
+ }
58
+ const securities = all().filter(s => s.exchange === market).sort((a, b) => a.code.localeCompare(b.code))
59
+ const start = Math.max(0, Math.trunc(Number(offset) || 0)), size = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 50)))
60
+ return { total: securities.length, items: securities.slice(start, start + size) }
61
+ },
62
+ resolve(id) {
63
+ const cached = all().find(s => s.id === id)
64
+ if (cached) return cached
65
+ const candidate = candidates.get(id)
66
+ return candidate && candidate.until > now() && candidate.revision === getControl().routingRevision ? candidate.security : null
67
+ },
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) })),
69
+ async remote(q, signal) {
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))
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)
91
+ }
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
+ }
104
+ }
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) }
114
+ },
115
+ // 内存占用可观测: 长跑核对缓存不越界
116
+ stats: () => ({ searchCache: searchCache.size, candidates: candidates.size, searchFlights: searchFlights.size, syncFlights: flights.size }),
117
+ async ensure() {
118
+ for (const route of getControl().routes.filter(r => ['catalog', 'calendar'].includes(r.capability))) {
119
+ if (getControl().providers.find(p => p.id === route.primaryInstanceId)?.budget.requestsPerMinute === null) continue
120
+ const caches = records(route.capability).filter(c => c.payload.market === route.market)
121
+ const cache = caches[0]
122
+ const today = localDate(now())
123
+ const missing = !cache || now() - cache.fetchedAt > (route.capability === 'calendar' ? 7 * DAY : DAY) || route.capability === 'calendar' && !cache.payload.items.some(day => day.date === today)
124
+ if (missing) await this.sync(route.market, [route.capability])
125
+ }
126
+ },
127
+ async sync(market, capabilities = ['catalog', 'calendar']) {
128
+ const config = structuredClone(getControl()), key = `${config.routingRevision}:${market}:${capabilities.join(',')}`
129
+ if (flights.has(key)) return flights.get(key)
130
+ const controller = new AbortController(); controllers.add(controller)
131
+ const job = (async () => {
132
+ const status = []
133
+ for (const capability of capabilities) {
134
+ try {
135
+ const selected = router.select(capability, market)
136
+ const request = { market, routingRevision: config.routingRevision, startDate: localDate(now() - 7 * DAY), endDate: localDate(now() + 45 * DAY) }
137
+ const batch = await executor.run(selected.instance, capability, request, { signal: controller.signal })
138
+ if (stopped || config.routingRevision !== getControl().routingRevision) fail('ROUTE_CONFLICT', '来源已变化,旧目录已丢弃', 409)
139
+ if (!Array.isArray(batch.items) || capability === 'catalog' && batch.items.some(s => !securitySchema.safeParse(s).success || s.exchange !== market) || capability === 'calendar' && batch.items.some(d => !/^\d{4}-\d{2}-\d{2}$/.test(d.date) || typeof d.isOpen !== 'boolean')) fail('BAD_PAYLOAD', '目录来源返回不受支持的证券身份', 502)
140
+ const items = batch.items
141
+ await state.cache.put(`${capability}:${selected.instance.id}:${market}`, { kind: capability, source: selected.instance.id, fetchedAt: now(), payload: { routingRevision: config.routingRevision, market, complete: batch.complete ?? true, items } })
142
+ status.push({ capability, ok: true, count: items.length }); changed()
143
+ } catch (e) { status.push({ capability, ok: false, error: { code: e.code, message: e.message } }) }
144
+ }
145
+ return status
146
+ })()
147
+ flights.set(key, job)
148
+ job.finally(() => { flights.delete(key); controllers.delete(controller) }).catch(() => {})
149
+ return job
150
+ },
151
+ marketState(market) {
152
+ const date = localDate(now()), day = records('calendar').filter(c => c.payload.market === market).flatMap(c => c.payload.items).find(d => d.date === date)
153
+ // 没同步过日历就按市场自身的时段判断, 别让八市场统一显示「未知」; 有日历数据时以日历(含节假日)为准
154
+ if (!day || typeof day.isOpen !== 'boolean') return { state: sessionStateOf(market, now()) }
155
+ const local = new Date(now() + 8 * 60 * 60 * 1000), minutes = local.getUTCHours() * 60 + local.getUTCMinutes()
156
+ const at = minute => Date.parse(`${date}T00:00:00+08:00`) + minute * 60 * 1000
157
+ if (!day.isOpen) return { state: 'closed', nextAt: at(24 * 60 + 9 * 60 + 15) }
158
+ if (minutes < 9 * 60 + 15) return { state: 'preopen', nextAt: at(9 * 60 + 15) }
159
+ if (minutes < 9 * 60 + 25) return { state: 'auction' }
160
+ if (minutes < 9 * 60 + 30) return { state: 'preopen', nextAt: at(9 * 60 + 30) }
161
+ if (minutes < 11 * 60 + 30 || minutes >= 13 * 60 && minutes < 15 * 60) return { state: 'trading' }
162
+ if (minutes < 13 * 60) return { state: 'break', nextAt: at(13 * 60) }
163
+ return { state: 'closed', nextAt: at(24 * 60 + 9 * 60 + 15) }
164
+ },
165
+ async dispose() { stopped = true; for (const c of controllers) c.abort(); candidates.clear(); await Promise.allSettled([...flights.values()]) },
166
+ }
167
+ }