@v587d/capital-generation 0.0.0-stage → 2.6.0

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +794 -0
  2. package/LICENSE +21 -0
  3. package/README.md +116 -2
  4. package/assets/cg-icon.png +0 -0
  5. package/capital-config/client.js +28 -0
  6. package/capital-config/client.src.cjs +388 -0
  7. package/capital-config/index.js +113 -0
  8. package/capital-config/package.json +36 -0
  9. package/capital-watchlist/client.js +182 -0
  10. package/capital-watchlist/client.src.cjs +1229 -0
  11. package/capital-watchlist/index.js +772 -0
  12. package/capital-watchlist/package.json +37 -0
  13. package/chart-ui/README.md +112 -0
  14. package/chart-ui/client.js +16 -0
  15. package/chart-ui/client.src.cjs +126 -0
  16. package/chart-ui/index.js +205 -0
  17. package/chart-ui/package.json +30 -0
  18. package/cordis.patch.yml +72 -0
  19. package/lib/agents/bash-guard.d.ts +55 -0
  20. package/lib/agents/bash-guard.js +162 -0
  21. package/lib/agents/root-tool-policy.d.ts +164 -0
  22. package/lib/agents/root-tool-policy.js +309 -0
  23. package/lib/chart/artifact-ref.d.ts +65 -0
  24. package/lib/chart/artifact-ref.js +107 -0
  25. package/lib/chart/downsample.d.ts +8 -0
  26. package/lib/chart/downsample.js +53 -0
  27. package/lib/chart/errors.d.ts +16 -0
  28. package/lib/chart/errors.js +15 -0
  29. package/lib/chart/events.d.ts +120 -0
  30. package/lib/chart/events.js +471 -0
  31. package/lib/chart/html.d.ts +21 -0
  32. package/lib/chart/html.js +154 -0
  33. package/lib/chart/runtime.d.ts +20 -0
  34. package/lib/chart/runtime.js +270 -0
  35. package/lib/chart/series.d.ts +102 -0
  36. package/lib/chart/series.js +424 -0
  37. package/lib/chart/source-token.d.ts +64 -0
  38. package/lib/chart/source-token.js +217 -0
  39. package/lib/chart/spec.d.ts +58 -0
  40. package/lib/chart/spec.js +265 -0
  41. package/lib/chart/tool.d.ts +86 -0
  42. package/lib/chart/tool.js +288 -0
  43. package/lib/chart/vendor/LICENSE +201 -0
  44. package/lib/chart/vendor/VERSION.json +8 -0
  45. package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
  46. package/lib/data-collector/args.d.ts +38 -0
  47. package/lib/data-collector/args.js +114 -0
  48. package/lib/data-collector/dataset-tools.d.ts +3 -0
  49. package/lib/data-collector/dataset-tools.js +421 -0
  50. package/lib/data-collector/describe.d.ts +99 -0
  51. package/lib/data-collector/describe.js +256 -0
  52. package/lib/data-collector/hub.d.ts +187 -0
  53. package/lib/data-collector/hub.js +469 -0
  54. package/lib/data-collector/query-time.d.ts +58 -0
  55. package/lib/data-collector/query-time.js +101 -0
  56. package/lib/data-collector/query.d.ts +57 -0
  57. package/lib/data-collector/query.js +473 -0
  58. package/lib/data-collector/store.d.ts +499 -0
  59. package/lib/data-collector/store.js +1486 -0
  60. package/lib/data-collector/time-axis.d.ts +169 -0
  61. package/lib/data-collector/time-axis.js +419 -0
  62. package/lib/data-collector/tools.d.ts +32 -0
  63. package/lib/data-collector/tools.js +216 -0
  64. package/lib/index.d.ts +275 -0
  65. package/lib/index.js +458 -0
  66. package/lib/net/eastmoney-client.d.ts +104 -0
  67. package/lib/net/eastmoney-client.js +118 -0
  68. package/lib/net/throttle.d.ts +16 -0
  69. package/lib/net/throttle.js +34 -0
  70. package/lib/ocr/client.d.ts +82 -0
  71. package/lib/ocr/client.js +281 -0
  72. package/lib/ocr/markdown.d.ts +75 -0
  73. package/lib/ocr/markdown.js +209 -0
  74. package/lib/ocr/store.d.ts +119 -0
  75. package/lib/ocr/store.js +226 -0
  76. package/lib/sources/eastmoney-http.d.ts +2 -0
  77. package/lib/sources/eastmoney-http.js +1253 -0
  78. package/lib/sources/fuyao-core.d.ts +52 -0
  79. package/lib/sources/fuyao-core.js +96 -0
  80. package/lib/sources/fuyao-rest.d.ts +14 -0
  81. package/lib/sources/fuyao-rest.js +2057 -0
  82. package/lib/sources/security-code.d.ts +27 -0
  83. package/lib/sources/security-code.js +104 -0
  84. package/lib/sources/security-identity.d.ts +24 -0
  85. package/lib/sources/security-identity.js +89 -0
  86. package/lib/sources/tencent-http.d.ts +2 -0
  87. package/lib/sources/tencent-http.js +1053 -0
  88. package/lib/sources/wind-mcp.d.ts +8 -0
  89. package/lib/sources/wind-mcp.js +593 -0
  90. package/lib/time/tools.d.ts +74 -0
  91. package/lib/time/tools.js +464 -0
  92. package/lib/tool-exec.d.ts +29 -0
  93. package/lib/tool-exec.js +17 -0
  94. package/lib/web-retriever/engines.d.ts +61 -0
  95. package/lib/web-retriever/engines.js +202 -0
  96. package/lib/web-retriever/html-markdown.d.ts +50 -0
  97. package/lib/web-retriever/html-markdown.js +321 -0
  98. package/lib/web-retriever/local-fetch.d.ts +121 -0
  99. package/lib/web-retriever/local-fetch.js +562 -0
  100. package/lib/web-retriever/retriever.d.ts +58 -0
  101. package/lib/web-retriever/retriever.js +94 -0
  102. package/lib/web-retriever/sources.d.ts +235 -0
  103. package/lib/web-retriever/sources.js +961 -0
  104. package/lib/web-retriever/tools.d.ts +50 -0
  105. package/lib/web-retriever/tools.js +1029 -0
  106. package/lib/web-retriever/wind-client.d.ts +69 -0
  107. package/lib/web-retriever/wind-client.js +311 -0
  108. package/locale/en.json +6 -0
  109. package/locale/zh.json +6 -0
  110. package/package.json +76 -4
  111. package/preset/capital-generation/README.md +161 -0
  112. package/preset/capital-generation/agent.patch.yml +509 -0
  113. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
  114. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
  115. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
  116. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
  117. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
@@ -0,0 +1,772 @@
1
+ /**
2
+ * `@v587d/capital-watchlist` 的 host half。
3
+ *
4
+ * 用户自选股是**宿主级(跨 workspace)的用户侧资产**:清单与报价快照住在官方
5
+ * `storage-domain` 域 `capital_watchlist`(`~/.dsh/storages/capital_watchlist.json`),
6
+ * 浏览器半边经 `/capital-watchlist/*` 读写。出网只由用户动作触发(开面板一次 + 手动
7
+ * 刷新按钮),没有轮询、没有定时器、没有自动重试。
8
+ *
9
+ * 三条纪律在这里落地:
10
+ * - ⛔ 认证围栏与 `/capital-charts` 同一写法(`connection.requestRejection`)——
11
+ * 不接就是"任何本机进程凭路径即可读写用户的自选股"。
12
+ * - 出网复用 `lib/sources/fuyao-core.js`(**同一份实现**,AGENTS.md §9.7);key 引用名
13
+ * 从 `capital-config` 卡片读,不硬编码 `FUYAO_API_KEY`。
14
+ * - 报价按 `asset_type` 分流;ETF 上游只接受单只(实测 `code=1002`),所以逐只扇出,规模由清单
15
+ * 上限(30)钉住、由 `REFRESH_CONCURRENCY` 收敛成约 8 波,总预算随条数走。
16
+ * - 清单的读-改-写在**进程内串行**(`serialize`):出网不进这一道,跨进程只到"同一键"为止,
17
+ * 两条边界都写在 `serialize` 的注释里,不假装我们锁住了磁盘。
18
+ */
19
+ import z from 'zod'
20
+ import { DEFAULT_FUYAO_BASE_URL, FuyaoError, fuyaoItems, fuyaoQuery, fuyaoRequest, fuyaoTimestamp } from '../lib/sources/fuyao-core.js'
21
+
22
+ export const name = 'capital-watchlist'
23
+
24
+ export const ROUTE_PATH = '/capital-watchlist'
25
+ /**
26
+ * 域名走 storage 的 **unit name** 规则:`UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/`
27
+ * (`@deepseek-ai/dsh-storage`),**连字符会被拒**——实测报
28
+ * `malformed-medium:invalid unit name 'capital-watchlist'`。所以路由叫
29
+ * `/capital-watchlist`,域却叫 `capital_watchlist`,落盘
30
+ * `~/.dsh/storages/capital_watchlist.json`。这不是风格问题,是上游硬约束。
31
+ */
32
+ export const DOMAIN_NAME = 'capital_watchlist'
33
+ /**
34
+ * 清单条目上限(2026-10-01 从 10 抬到 30:用户点名 10 条不可接受)。它同时是**一次刷新的
35
+ * 请求个数上限**——ETF 上游只认单只,30 只全 ETF 就是 30 个请求,所以扇出必须并发(见
36
+ * `REFRESH_CONCURRENCY`)并且总预算随条数走(见 `refreshBudgetFor`);它也约束 `single`
37
+ * 布局的整写体积。
38
+ */
39
+ export const MAX_ITEMS = 30
40
+ /** ETF 扇出的并发路数:30 个单只请求压成约 8 波,正常网络一次刷新几秒内落地。 */
41
+ export const REFRESH_CONCURRENCY = 4
42
+ /** 搜索候选上限:倒逼用户缩小输入,不做分页。 */
43
+ export const SEARCH_LIMIT = 10
44
+ /** 本版标的范围(Fuyao 与报价端点双端都有可靠支撑);场外基金 / 北交所不进候选。 */
45
+ export const SEARCH_ASSET_TYPES = 'a-share,a-share-index,fund-etf'
46
+ const ASSET_TYPES = ['a-share', 'a-share-index', 'fund-etf']
47
+ const EXCHANGES = ['SH', 'SZ']
48
+ const SEARCH_TIMEOUT_MS = 15_000
49
+ /**
50
+ * 一次刷新的总预算:**随清单条数走**——`20s + 2.5s × 条数`,下界 45 秒、上界 90 秒。
51
+ * 下界保住 10 条这一档与 2.5.2 的行为完全一致(20 + 2.5×10 = 45),抬到 30 条时不必把小清单
52
+ * 一起拖长。预算在**每个工作线程领下一组之前**检查,所以真实上界是 `budget + 一次请求的超时`
53
+ * (一次超时 = `SEARCH_TIMEOUT_MS`);被预算切掉的行如实记 `refresh_timeout`——它们这次没被问过,
54
+ * 不是"这个标的没有报价"。最坏情形(每只都撞超时)由并发路数收敛:30 只 ETF ≈ 8 波。
55
+ */
56
+ const REFRESH_BUDGET_BASE_MS = 20_000
57
+ const REFRESH_BUDGET_PER_ITEM_MS = 2_500
58
+ const REFRESH_BUDGET_FLOOR_MS = 45_000
59
+ const REFRESH_BUDGET_CEIL_MS = 90_000
60
+ /** 一次刷新的总预算:`options.refreshBudgetMs` 给了就用给的,没给按这次要问的条数算。 */
61
+ export function refreshBudgetFor(count) {
62
+ return Math.min(REFRESH_BUDGET_CEIL_MS, Math.max(REFRESH_BUDGET_FLOOR_MS, REFRESH_BUDGET_BASE_MS + REFRESH_BUDGET_PER_ITEM_MS * count))
63
+ }
64
+ const MAX_BODY_BYTES = 65_536
65
+ const MAX_QUERY_LENGTH = 64
66
+ /** 与 `capital-config` 卡片同一条目 id:0.1.7 起 settings 命名空间恒等于 profile 条目 id。 */
67
+ const CAPITAL_CONFIG_ENTRY_ID = 'capital-config'
68
+ const DEFAULT_CREDENTIAL_REF = 'FUYAO_API_KEY'
69
+
70
+ const ENDPOINTS = {
71
+ search: '/api/meta/tickers/search',
72
+ stock: '/api/a-share/prices/snapshot',
73
+ index: '/api/a-share-index/prices/snapshot',
74
+ fund: '/api/fund/market/snapshot',
75
+ }
76
+
77
+ /**
78
+ * 播种条目:主要沪深指数,`thscode` 用 Fuyao 的规范化形态(带后缀),报价走 `index`
79
+ * 端点。四条均已用 2026-09-28 真报文验证有值(`last_price` / `price_change_ratio_pct`)。
80
+ */
81
+ export const SEED_ITEMS = [
82
+ { thscode: '000001.SH', ticker: '000001', name: '上证指数', exchange: 'SH', asset_type: 'a-share-index' },
83
+ { thscode: '399001.SZ', ticker: '399001', name: '深证成指', exchange: 'SZ', asset_type: 'a-share-index' },
84
+ { thscode: '399006.SZ', ticker: '399006', name: '创业板指', exchange: 'SZ', asset_type: 'a-share-index' },
85
+ { thscode: '000300.SH', ticker: '000300', name: '沪深300', exchange: 'SH', asset_type: 'a-share-index' },
86
+ ]
87
+
88
+ const quoteSchema = z.object({
89
+ price: z.number(),
90
+ /**
91
+ * 涨跌可以是**没有**:上游用 `null` 表示"这个数没有"(停牌、未开盘),此时最新价仍然有值。
92
+ * 早先这里写死 `z.number()`,于是 `quoteOf` 拿 `pct ?? 0` 兜底——停牌标的被画成「+0.00%」,
93
+ * 一次刷新把上一次真实的涨跌顶掉、页脚照报"刷新于此刻"、还不记任何失败。金融应用里
94
+ * "没有这个数"和"这个数是零"必须分开;客户端把 null 画成破折号并降到陈旧那一档的灰。
95
+ */
96
+ change_pct: z.number().nullable(),
97
+ captured_at: z.number().int(),
98
+ source_ts: z.number().int().nullable(),
99
+ })
100
+
101
+ const itemSchema = z.object({
102
+ thscode: z.string().regex(/^\d{6}\.(SH|SZ)$/u),
103
+ ticker: z.string().min(1),
104
+ name: z.string().min(1),
105
+ exchange: z.enum(EXCHANGES),
106
+ asset_type: z.enum(ASSET_TYPES),
107
+ added_at: z.number().int(),
108
+ source: z.enum(['seed', 'user']),
109
+ quote: quoteSchema.nullable(),
110
+ /**
111
+ * 置顶时刻(用户动作写入;缺省 = 未置顶,读法见 `rowsOf`)。
112
+ * **可选是刻意的**:磁盘上已有的 v1 记录没有这一格,写成必填会把整份清单判成
113
+ * `invalid-record`(域在加载边界逐条校验,读一次就 `store_unavailable`),而新写的记录
114
+ * 永远带它——两个方向的兼容因此都不需要迁移、也不需要抬 domain version。
115
+ */
116
+ pinned_at: z.number().int().optional(),
117
+ })
118
+
119
+ /**
120
+ * 域声明。`defineDomain` 只是同一个 spec 的恒等校验器,host 半边因此不 import dsh 的
121
+ * 运行时包、只 import `zod`——与 `capital-config` 只 import schemastery 是同一条纪律
122
+ * (少一个上游耦合面;域名与版本号由下面的常量与 schema 自己保证)。
123
+ */
124
+ export const domainSpec = {
125
+ name: DOMAIN_NAME,
126
+ version: 1,
127
+ layout: 'single',
128
+ global: {
129
+ schema: z.object({ seeded_at: z.number().int(), schema: z.literal(1) }),
130
+ // `seeded_at: 0` = 从未播种。用户删空后不重建,靠的就是这个非 0 的戳。
131
+ initial: { seeded_at: 0, schema: 1 },
132
+ },
133
+ tables: { items: { valueSchema: itemSchema } },
134
+ }
135
+
136
+ /** 规范化 `thscode`:trim + 大写;只接受 6 位数字 + `.SH`/`.SZ`(裸代码一律拒绝)。 */
137
+ export function normalizeThscode(value) {
138
+ if (typeof value !== 'string') return undefined
139
+ const cleaned = value.trim().toUpperCase()
140
+ return /^\d{6}\.(SH|SZ)$/u.test(cleaned) ? cleaned : undefined
141
+ }
142
+
143
+ /**
144
+ * `FuyaoError.kind` → 本路由自己的错误码。`perItem` 决定"这个标的取不到"还是"服务不通":
145
+ * 逐条失败绝不能升格成整批失败,整批失败也绝不能伪装成某一条的缺口。
146
+ */
147
+ export function watchlistErrorCode(error, { perItem = false } = {}) {
148
+ if (!(error instanceof FuyaoError)) return perItem ? 'quote_unavailable' : 'fuyao_unavailable'
149
+ switch (error.kind) {
150
+ case 'credential_missing':
151
+ return 'credential_missing'
152
+ case 'rate_limited':
153
+ return 'rate_limited'
154
+ case 'invalid_parameter':
155
+ return perItem ? 'quote_unavailable' : 'invalid_query'
156
+ case 'capability_closed':
157
+ case 'http_error':
158
+ case 'upstream_error':
159
+ default:
160
+ return perItem ? 'quote_unavailable' : 'fuyao_unavailable'
161
+ }
162
+ }
163
+
164
+ /**
165
+ * 清单读法(KvTable 只有 `entries()`,没有 `values()`)+ 置顶排序,**只有这一份实现**:
166
+ * 带 `pinned_at` 的排前面(后置顶的在前),其余保持表里的插入顺序(用原始下标兜底,
167
+ * 比较稳定)。持久化文件里的键序**不动**——顺序是"读"出来的,不是"写"进去的:
168
+ * 官方单文件后端是 Map,`put` 已有键保留原位置,靠删了重插来改顺序会把整份 JSON 写 N 遍。
169
+ */
170
+ function rowsOf(table) {
171
+ return [...table.entries()]
172
+ .map(([, value], index) => ({ value, index }))
173
+ .sort((left, right) => {
174
+ const a = left.value.pinned_at ?? null
175
+ const b = right.value.pinned_at ?? null
176
+ if (a !== null && b !== null && a !== b) return b - a
177
+ if (a !== null && b === null) return -1
178
+ if (a === null && b !== null) return 1
179
+ return left.index - right.index
180
+ })
181
+ .map((entry) => entry.value)
182
+ }
183
+
184
+ function candidateOf(row) {
185
+ const thscode = typeof row.thscode === 'string' ? row.thscode : ''
186
+ return {
187
+ thscode,
188
+ ticker: typeof row.ticker === 'string' && row.ticker.length > 0 ? row.ticker : thscode.split('.')[0],
189
+ name: typeof row.name === 'string' && row.name.length > 0 ? row.name : thscode,
190
+ exchange: typeof row.exchange === 'string' && row.exchange.length > 0 ? row.exchange.toUpperCase() : (thscode.split('.')[1] ?? ''),
191
+ asset_type: typeof row.asset_type === 'string' ? row.asset_type : '',
192
+ }
193
+ }
194
+
195
+ /**
196
+ * 上游用 `null` 表示"这个数没有"(本仓 `fuyao-rest.ts` 的 `matchesField` 就是为此放行 null 的,
197
+ * 理由是不能让一个指标缺失打挂整份数据)。这里绝不能拿 `Number()` 硬转:`Number(null)` 是 **0**,
198
+ * 停牌 / 未开盘的标的会被写成一份"0.00 元、涨跌 0%"的新快照——顶掉上一次成功的值、页脚照报
199
+ * "刷新于此刻"、还不记任何失败。数字字符串照收(与 `matchesField` 的 number 分支同口径)。
200
+ */
201
+ function numberValue(value) {
202
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined
203
+ if (typeof value !== 'string' || value.trim().length === 0) return undefined
204
+ const parsed = Number(value)
205
+ return Number.isFinite(parsed) ? parsed : undefined
206
+ }
207
+
208
+ function quoteOf(row, sourceTs, capturedAt) {
209
+ const price = numberValue(row.last_price)
210
+ if (price === undefined) return undefined
211
+ const pct = numberValue(row.price_change_ratio_pct)
212
+ return {
213
+ price,
214
+ // **没有涨跌就是没有**,不许补 0:`0` 在 A 股口径里是一个真值(今日平盘),把它写成
215
+ // 停牌 / 未开盘的标的,等于伪造一份"横盘"的新快照顶掉上一次的真涨跌,还不记任何失败。
216
+ // 客户端读到 null 画破折号(与"取不到报价"同一档,而不是红/绿/平三档里的平)。
217
+ change_pct: pct ?? null,
218
+ captured_at: capturedAt,
219
+ source_ts: typeof sourceTs === 'number' ? sourceTs : null,
220
+ }
221
+ }
222
+
223
+ /**
224
+ * 自选股服务本体:域打开、key 解析、Fuyao base URL 全部注入,于是 store / route / 出网
225
+ * 三组断言都不必碰真实文件系统与真实网络。
226
+ *
227
+ * @param options.openDomain - `() => Promise<Domain>`(host 里是 `storageDomain.open`)。
228
+ * @param options.resolveApiKey - `(ref: string) => Promise<string | undefined>`。
229
+ * @param options.credentialRef - `() => string`,取自 `capital-config` 卡片。
230
+ * @param options.baseUrl - Fuyao base URL;省略时用内核里的官方默认值。
231
+ * @param options.now - 时间源(测试可注入)。
232
+ * @param options.refreshBudgetMs - 一次刷新的总预算;省略时按这次要问的条数算(`refreshBudgetFor`)。
233
+ */
234
+ export function createWatchlistService(options = {}) {
235
+ const {
236
+ openDomain,
237
+ resolveApiKey = async () => undefined,
238
+ credentialRef = () => DEFAULT_CREDENTIAL_REF,
239
+ baseUrl = DEFAULT_FUYAO_BASE_URL,
240
+ now = () => Date.now(),
241
+ timeoutMs = SEARCH_TIMEOUT_MS,
242
+ refreshBudgetMs,
243
+ } = options
244
+
245
+ let domainPromise
246
+ /** 全局单闸门:开面板与手动刷新共用;后来者等同一次结果,而不是并排放第二个请求。 */
247
+ let refreshing = null
248
+
249
+ /**
250
+ * 域只开一次,但**开失败的痕迹不许留下**。`storageDomain` 是 `dsh-storage-domain` 在自己
251
+ * `inject(backendServices, …)` 的回调里才 provide 的——host 平面这一行的预热完全可能跑在它前面,
252
+ * 把 rejection memoize 住就等于"装配期抢跑一次,功能锁到进程结束"(AGENTS.md §9.7 ⑤ 的形状,
253
+ * 与 webServer 那条同一个族)。facility 的 `open()` 失败时已经 `reserved.delete`,所以重试安全。
254
+ */
255
+ const domain = () => {
256
+ if (domainPromise === undefined) {
257
+ const opening = Promise.resolve().then(() => openDomain())
258
+ domainPromise = opening
259
+ opening.catch(() => {
260
+ if (domainPromise === opening) domainPromise = undefined
261
+ })
262
+ }
263
+ return domainPromise
264
+ }
265
+ const table = async () => (await domain()).table('items')
266
+
267
+ /**
268
+ * 清单的**读-改-写串行队列**(进程内)。同进程的多个窗口打的是同一条路由、同一份内存域,
269
+ * 所以它们互相看得见;但这里原本没有互斥:`add` 的上限检查发生在最长 15 秒的上游搜索**之前**,
270
+ * 两个并发 `/add` 都先通过 `size >= MAX_ITEMS`、再各写一条 → 清单能写成 31 条(2.5.2 是 11 条)。
271
+ * 刷新写回拿批次快照整条覆写、撤销并发删除与置顶,也是同一族(下面 `doRefresh` 里逐键重读那一处)。
272
+ *
273
+ * 出网**不进**这一道:只有"读当前记录 → 改 → 落盘"这几步排队。否则一次预算 90 秒的刷新会把
274
+ * 用户随后的删除与置顶全堵在门外,而那正是这次审查里用户体验最差的一条。
275
+ *
276
+ * ⛔ 射程只有本进程。跨进程(桌面端 App 与本机的另一个宿主 profile)共用同一个
277
+ * `~/.dsh/storages/capital_watchlist.json`,官方原子写只保证文件不撕裂、不保证不丢更新;
278
+ * 清单必须走官方 storage-domain,本仓不许自建文件锁或文件路径,所以这里的边界是**如实收窄**
279
+ * 而不是解决:写回改成逐键重读之后,跨进程丢失面从"整张清单"缩到"同一只标的的报价那一格"。
280
+ */
281
+ let queue = Promise.resolve()
282
+ function serialize(task) {
283
+ const run = queue.then(task, task)
284
+ // 队列本身不能被一次失败堵住:吞掉这一环的 rejection,让后来者照常跑。
285
+ queue = run.then(() => {}, () => {})
286
+ return run
287
+ }
288
+
289
+ async function ensureSeeded() {
290
+ const opened = await domain()
291
+ return serialize(async () => {
292
+ const items = opened.table('items')
293
+ if (opened.global.get().seeded_at !== 0 || items.size > 0) return
294
+ const stamp = now()
295
+ for (const seed of SEED_ITEMS) {
296
+ await items.put(seed.thscode, { ...seed, added_at: stamp, source: 'seed', quote: null })
297
+ }
298
+ await opened.global.set({ seeded_at: stamp, schema: 1 })
299
+ })
300
+ }
301
+
302
+ async function callFuyao(path, params, allowed) {
303
+ const ref = credentialRef() || DEFAULT_CREDENTIAL_REF
304
+ const apiKey = await resolveApiKey(ref)
305
+ return fuyaoRequest({ baseUrl, apiKey, path, search: fuyaoQuery(params, allowed), signal: AbortSignal.timeout(timeoutMs) })
306
+ }
307
+
308
+ async function search(query) {
309
+ const q = typeof query === 'string' ? query.trim() : ''
310
+ if (q.length === 0 || q.length > MAX_QUERY_LENGTH) {
311
+ return { ok: false, code: 'invalid_query', message: '输入为空或过长' }
312
+ }
313
+ // 先碰存储再出网:域打不开时 `/search` 不该白烧一次上游配额(候选也根本写不进清单)。
314
+ // 存储失败因此从这一路也走响亮那条(路由层的逐次 catch → store_unavailable),不靠预热闩住。
315
+ const items = await table()
316
+ let envelope
317
+ try {
318
+ envelope = await callFuyao(ENDPOINTS.search, { q, asset_type: SEARCH_ASSET_TYPES, limit: SEARCH_LIMIT }, ['q', 'asset_type', 'limit'])
319
+ } catch (error) {
320
+ const code = watchlistErrorCode(error)
321
+ return { ok: false, code, message: messageFor(code) }
322
+ }
323
+ // 后缀也要收口:实测 `q=宁德时代` 会带回 `885789.TI 宁德时代概念`(同花顺指数),
324
+ // 它既不在本版报价范围、也过不了域 schema——不挡掉就是"添加"那一下 500。
325
+ const rows = fuyaoItems(envelope)
326
+ .map(candidateOf)
327
+ .filter((row) => ASSET_TYPES.includes(row.asset_type) && normalizeThscode(row.thscode) !== undefined)
328
+ return {
329
+ ok: true,
330
+ items: rows.map((row) => ({ ...row, in_list: items.get(row.thscode) !== undefined })),
331
+ truncated: rows.length >= SEARCH_LIMIT,
332
+ }
333
+ }
334
+
335
+ async function list() {
336
+ await ensureSeeded()
337
+ const opened = await domain()
338
+ return { ok: true, items: rowsOf(opened.table('items')), seeded_at: opened.global.get().seeded_at }
339
+ }
340
+
341
+ async function add(input) {
342
+ await ensureSeeded()
343
+ const items = await table()
344
+ // `limit` 跟着回包走:客户端那句「最多 N 条,先删一条再加」不许自己抄一份数字(2.5.2 就是
345
+ // 服务端常量 10 + 中文字典写死"10"两处各写一遍,抬上限只会改红一边)。
346
+ const full = () => ({ ok: false, code: 'list_full', limit: MAX_ITEMS, message: `自选股最多 ${MAX_ITEMS} 条,先删一条再加` })
347
+
348
+ // 先到上限就不出网(一次搜索也是配额);**这挡不住并发**,锁里还要再验一次,见下。
349
+ if (items.size >= MAX_ITEMS) return full()
350
+
351
+ const direct = input?.thscode
352
+ if (direct !== undefined && direct !== null && direct !== '') {
353
+ const thscode = normalizeThscode(direct)
354
+ if (thscode === undefined) return { ok: false, code: 'invalid_query', message: '代码格式不符:需要 6 位数字 + 交易所后缀' }
355
+ const found = await search(thscode)
356
+ if (!found.ok) return found
357
+ const hit = found.items.find((row) => row.thscode === thscode)
358
+ if (hit === undefined) return { ok: false, code: 'not_found', message: '该标的不在本版支持范围(沪深 A 股 / 指数 / 场内 ETF)' }
359
+ return serialize(() => {
360
+ // 出网最长 15 秒,这期间别的窗口可能已经填满:上限必须在"读 size → put"这一段的锁内复查。
361
+ if (items.size >= MAX_ITEMS) return full()
362
+ return write(hit, items)
363
+ })
364
+ }
365
+
366
+ const found = await search(input?.q)
367
+ if (!found.ok) return found
368
+ if (found.items.length === 0) return { ok: false, code: 'not_found', message: '没有找到该标的' }
369
+ if (found.items.length > 1) return { ok: false, code: 'ambiguous', message: '命中多条,请从候选里选一条', candidates: found.items }
370
+ return serialize(() => {
371
+ if (items.size >= MAX_ITEMS) return full()
372
+ return write(found.items[0], items)
373
+ })
374
+ }
375
+
376
+ async function write(candidate, items) {
377
+ const existing = items.get(candidate.thscode)
378
+ if (existing !== undefined) return { ok: true, item: existing, idempotent: true }
379
+ const item = {
380
+ thscode: candidate.thscode,
381
+ ticker: candidate.ticker,
382
+ name: candidate.name,
383
+ exchange: candidate.exchange,
384
+ asset_type: candidate.asset_type,
385
+ added_at: now(),
386
+ source: 'user',
387
+ quote: null,
388
+ }
389
+ await items.put(item.thscode, item)
390
+ return { ok: true, item }
391
+ }
392
+
393
+ async function remove(input) {
394
+ await ensureSeeded()
395
+ const items = await table()
396
+ const thscode = normalizeThscode(input?.thscode)
397
+ if (thscode === undefined) return { ok: false, code: 'invalid_query', message: '代码格式不符' }
398
+ return serialize(async () => ({ ok: true, removed: await items.delete(thscode) }))
399
+ }
400
+
401
+ /**
402
+ * 置顶 = 给这一条打一个 `pinned_at` 时间戳;排序归 `rowsOf`(后置顶的在前)。
403
+ * 不把"键挪到最前"当成实现:顺序是读法的一部分,写进去就要求整表重写。
404
+ * 重复置顶是幂等的用户动作(只是把时间戳推新,行不会跳走),`not_found` 则响亮失败。
405
+ */
406
+ async function pin(input) {
407
+ await ensureSeeded()
408
+ const items = await table()
409
+ const thscode = normalizeThscode(input?.thscode)
410
+ if (thscode === undefined) return { ok: false, code: 'invalid_query', message: '代码格式不符' }
411
+ // 读 → 改 → 写在同一环里:否则"取到旧记录"与"put 回去"之间正好删了这一行,
412
+ // 置顶会把刚删掉的标的连同它的旧报价一起写回来(和刷新写回同一族,见 `doRefresh`)。
413
+ return serialize(async () => {
414
+ const existing = items.get(thscode)
415
+ if (existing === undefined) return { ok: false, code: 'not_found', message: '该标的不在自选清单里' }
416
+ await items.put(thscode, { ...existing, pinned_at: now() })
417
+ return { ok: true, items: rowsOf(items) }
418
+ })
419
+ }
420
+
421
+ /** 一个 asset_type 分组 = 一次快照请求;单组失败只记该组,不拖垮其它组。 */
422
+ async function fetchGroup(group) {
423
+ const capturedAt = now()
424
+ let envelope
425
+ try {
426
+ envelope = await callFuyao(group.path, group.params, group.allowed)
427
+ } catch (error) {
428
+ const code = watchlistErrorCode(error, { perItem: true })
429
+ const batch = batchLevelCode(error)
430
+ return {
431
+ byCode: new Map(),
432
+ failures: group.codes.map((thscode) => ({ thscode, code })),
433
+ batchError: batch !== undefined ? { code: batch, message: messageFor(batch) } : undefined,
434
+ }
435
+ }
436
+ const sourceTs = fuyaoTimestamp(envelope)
437
+ const byCode = new Map()
438
+ for (const row of fuyaoItems(envelope)) {
439
+ const thscode = typeof row.thscode === 'string' ? row.thscode : ''
440
+ const quote = quoteOf(row, sourceTs, capturedAt)
441
+ if (thscode !== '' && quote !== undefined) byCode.set(thscode, quote)
442
+ }
443
+ return {
444
+ byCode,
445
+ failures: group.codes.filter((thscode) => !byCode.has(thscode)).map((thscode) => ({ thscode, code: 'quote_unavailable' })),
446
+ batchError: undefined,
447
+ }
448
+ }
449
+
450
+ /**
451
+ * 单闸门:开面板、手动刷新、以及添加完那一次顺手刷新共用;并发只发一次出网。
452
+ * 复位必须发生在**同一条 settle 链**里(`.finally` 挂在 doRefresh 上),否则刚刷完再点
453
+ * 一次会拿到上一次的结果——用户看到的症状是"点了刷新没反应"。
454
+ */
455
+ function refresh() {
456
+ if (refreshing !== null) return refreshing
457
+ const running = doRefresh().finally(() => {
458
+ if (refreshing === running) refreshing = null
459
+ })
460
+ refreshing = running
461
+ return running
462
+ }
463
+
464
+ async function doRefresh() {
465
+ await ensureSeeded()
466
+ const opened = await domain()
467
+ const items = opened.table('items')
468
+ // 这份快照**只用来决定这次要问哪些代码**,不是写回的基底(写回逐键重读,见下)。
469
+ const batch = rowsOf(items)
470
+ if (batch.length === 0) return { ok: true, items: [], refreshed_at: null, failures: [] }
471
+
472
+ const groups = []
473
+ for (const [assetType, group] of [
474
+ ['a-share', { path: ENDPOINTS.stock, allowed: ['thscodes'], batch: true }],
475
+ ['a-share-index', { path: ENDPOINTS.index, allowed: ['thscodes'], batch: true }],
476
+ ['fund-etf', { path: ENDPOINTS.fund, allowed: ['thscode'], batch: false }],
477
+ ]) {
478
+ const codes = batch.filter((row) => row.asset_type === assetType).map((row) => row.thscode)
479
+ if (codes.length === 0) continue
480
+ if (!group.batch) {
481
+ for (const thscode of codes) groups.push({ path: group.path, allowed: group.allowed, params: { thscode }, codes: [thscode] })
482
+ } else {
483
+ groups.push({ path: group.path, allowed: group.allowed, params: { thscodes: codes.join(',') }, codes })
484
+ }
485
+ }
486
+
487
+ const budgetMs = refreshBudgetMs ?? refreshBudgetFor(batch.length)
488
+ const startedAt = now()
489
+ const results = new Array(groups.length)
490
+ // `cursor` 的自增在单线程里是原子的:每个工作线程领到一个**独占**的组号。
491
+ let cursor = 0
492
+ const worker = async () => {
493
+ for (;;) {
494
+ const index = cursor
495
+ cursor += 1
496
+ if (index >= groups.length) return
497
+ // 0 号组永远跑(否则一次都刷不动);之后每领一组先看预算:超了就停在这里,
498
+ // 剩下的行如实记 refresh_timeout——它们这次没被问过,不是"没有报价"。
499
+ if (index > 0 && now() - startedAt >= budgetMs) return
500
+ results[index] = await fetchGroup(groups[index])
501
+ }
502
+ }
503
+ await Promise.all(Array.from({ length: Math.min(REFRESH_CONCURRENCY, groups.length) }, () => worker()))
504
+
505
+ // 按**组序**归并,不按完成序:回包里的 failures 顺序是断言与面板读法的依赖,
506
+ // 并发不能把它变成"谁先回来谁在前"。
507
+ const failures = []
508
+ const quotes = new Map()
509
+ let batchError
510
+ for (let index = 0; index < groups.length; index += 1) {
511
+ const result = results[index]
512
+ if (result === undefined) {
513
+ for (const thscode of groups[index].codes) failures.push({ thscode, code: 'refresh_timeout' })
514
+ continue
515
+ }
516
+ for (const [thscode, quote] of result.byCode) quotes.set(thscode, quote)
517
+ for (const failure of result.failures) failures.push(failure)
518
+ if (result.batchError !== undefined) batchError = result.batchError
519
+ }
520
+
521
+ // 写回整段进串行队列,并且**逐键重读当前记录**,只换 quote 那一格:
522
+ // 拿批次快照整条覆写会撤销刷新在途期间用户的两个动作——刚删的标的被原样写回来
523
+ // (还重新占掉一格),刚置顶的被旧记录里的空 pinned_at 盖掉、静默掉回原位。
524
+ // 面板一打开就自动刷新,所以这扇窗几乎每次都开着;ETF 逐只扇出时它最宽。
525
+ const written = await serialize(async () => {
526
+ let count = 0
527
+ for (const [thscode, quote] of quotes) {
528
+ const fresh = items.get(thscode)
529
+ if (fresh === undefined) continue // 在途被删:不复活、不占格。
530
+ await items.put(thscode, { ...fresh, quote })
531
+ count += 1
532
+ }
533
+ return { count, items: rowsOf(items) }
534
+ })
535
+ // 一条都没落地就不报"刷新于此刻":页脚那个时间讲的是最后一次**真取到数**的时刻,
536
+ // 跟着一次全失败的点击往前走,就成了每一行都陈旧、只有页脚是新的(客户端据此保留上一个值)。
537
+ const result = { ok: true, items: written.items, refreshed_at: written.count > 0 ? now() : null, failures }
538
+ if (batchError !== undefined) result.error = batchError
539
+ return result
540
+ }
541
+
542
+ return {
543
+ list,
544
+ search,
545
+ add,
546
+ remove,
547
+ pin,
548
+ refresh,
549
+ async close() {
550
+ const opened = await domainPromise
551
+ domainPromise = undefined
552
+ if (opened?.close !== undefined) await opened.close()
553
+ },
554
+ }
555
+ }
556
+
557
+ /** 只有会把整批都打挂的失败才升格成 batch error(限流 / 没配 key / 服务不通)。 */
558
+ function batchLevelCode(error) {
559
+ const code = watchlistErrorCode(error)
560
+ return code === 'credential_missing' || code === 'rate_limited' || code === 'fuyao_unavailable' ? code : undefined
561
+ }
562
+
563
+ function messageFor(code) {
564
+ if (code === 'credential_missing') return '未配置 Fuyao 密钥:在「插件」页的 Capital 模式卡片里填 API Key'
565
+ if (code === 'rate_limited') return '行情服务限流,请稍后再试'
566
+ if (code === 'invalid_query') return '输入不被接受'
567
+ return '行情服务暂时不可用,请稍后再试'
568
+ }
569
+
570
+ /** 读 `capital-config` 条目里用户配的 Fuyao 引用名;没有 settings 的载体回退默认名。 */
571
+ export function readCredentialRef(ctx) {
572
+ try {
573
+ const settings = ctx.get('settings')
574
+ const value = settings?.describe?.().find((entry) => entry.ns === CAPITAL_CONFIG_ENTRY_ID)?.value
575
+ const ref = value?.fuyaoCredentialRef
576
+ return typeof ref === 'string' && ref.trim().length > 0 ? ref.trim() : DEFAULT_CREDENTIAL_REF
577
+ } catch {
578
+ return DEFAULT_CREDENTIAL_REF
579
+ }
580
+ }
581
+
582
+ async function readBody(req) {
583
+ const chunks = []
584
+ let size = 0
585
+ for await (const chunk of req) {
586
+ size += chunk.length
587
+ if (size > MAX_BODY_BYTES) throw new Error('body too large')
588
+ chunks.push(chunk)
589
+ }
590
+ if (size === 0) return {}
591
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
592
+ }
593
+
594
+ function send(res, status, payload) {
595
+ res.statusCode = status
596
+ res.setHeader('content-type', 'application/json; charset=utf-8')
597
+ res.setHeader('cache-control', 'no-store')
598
+ res.end(JSON.stringify(payload))
599
+ }
600
+
601
+ const FAILURE_STATUS = {
602
+ invalid_query: 400,
603
+ not_found: 404,
604
+ ambiguous: 409,
605
+ list_full: 409,
606
+ credential_missing: 503,
607
+ rate_limited: 429,
608
+ fuyao_unavailable: 502,
609
+ store_unavailable: 503,
610
+ }
611
+
612
+ /**
613
+ * 路由:GET 走 query,POST 走 JSON body。
614
+ *
615
+ * @param service - `createWatchlistService` 的返回。
616
+ * @param options.authorize - `(req) => 401 | 403 | undefined`,接 `connection.requestRejection`。
617
+ */
618
+ export function createRouteHandler(service, options = {}) {
619
+ const { authorize } = options
620
+
621
+ return async function handler(req, res) {
622
+ if (typeof authorize === 'function') {
623
+ let rejection
624
+ try {
625
+ rejection = authorize(req)
626
+ } catch {
627
+ rejection = 401
628
+ }
629
+ if (rejection !== undefined && rejection !== null) {
630
+ res.statusCode = rejection
631
+ res.setHeader('content-type', 'text/plain; charset=utf-8')
632
+ res.setHeader('cache-control', 'no-store')
633
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
634
+ return
635
+ }
636
+ }
637
+
638
+ const method = req?.method ?? 'GET'
639
+ if (method !== 'GET' && method !== 'POST') {
640
+ send(res, 405, { ok: false, code: 'method_not_allowed', message: '只支持 GET / POST' })
641
+ return
642
+ }
643
+
644
+ let pathname
645
+ let searchParams
646
+ try {
647
+ const url = new URL(req.url ?? '/', 'http://localhost')
648
+ pathname = url.pathname.slice(ROUTE_PATH.length)
649
+ searchParams = url.searchParams
650
+ } catch {
651
+ send(res, 400, { ok: false, code: 'invalid_query', message: 'bad request' })
652
+ return
653
+ }
654
+
655
+ let input = {}
656
+ if (method === 'POST') {
657
+ try {
658
+ input = await readBody(req)
659
+ } catch {
660
+ send(res, 400, { ok: false, code: 'invalid_query', message: '请求体不是合法 JSON' })
661
+ return
662
+ }
663
+ }
664
+
665
+ try {
666
+ if (method === 'POST' && pathname === '/add') return sendBusiness(res, await service.add(input))
667
+ if (method === 'POST' && pathname === '/remove') return sendBusiness(res, await service.remove(input))
668
+ if (method === 'POST' && pathname === '/pin') return sendBusiness(res, await service.pin(input))
669
+ if (method === 'POST' && pathname === '/refresh') {
670
+ // 刷新是"部分成功 + 逐条失败"的形态:整批失败也带 items 与 error 回来,
671
+ // 面板按 error.code 居中显示,绝不清空列表。
672
+ return send(res, 200, await service.refresh())
673
+ }
674
+ if (method === 'GET' && pathname === '/list') return send(res, 200, await service.list())
675
+ if (method === 'GET' && pathname === '/search') return sendBusiness(res, await service.search(searchParams.get('q') ?? ''))
676
+ send(res, 404, { ok: false, code: 'not_found', message: 'unknown watchlist route' })
677
+ } catch (error) {
678
+ send(res, 503, { ok: false, code: 'store_unavailable', message: '自选股存储不可用', cause: storeCause(error) })
679
+ }
680
+ }
681
+ }
682
+
683
+ /**
684
+ * 存储侧失败的对外一句话:code 与 message 都要留。只留 code 运维看的是天书(实测
685
+ * `malformed-medium` 单独出现时完全指不出是哪份介质、哪一步),只留 message 又丢掉
686
+ * `backend-not-found` / `invalid-record` 这类可定位的判据。
687
+ */
688
+ function storeCause(error) {
689
+ return [error?.code, error?.message ?? error].filter(Boolean).join(':') || 'unknown'
690
+ }
691
+
692
+ function sendBusiness(res, result) {
693
+ if (result.ok === true) return send(res, 200, result)
694
+ return send(res, FAILURE_STATUS[result.code] ?? 400, result)
695
+ }
696
+
697
+ export function apply(ctx) {
698
+ const service = createWatchlistService({
699
+ openDomain: () => {
700
+ let storage
701
+ try {
702
+ storage = ctx.get('storageDomain')
703
+ } catch {
704
+ storage = undefined
705
+ }
706
+ if (storage?.open === undefined) throw new Error('storageDomain facility is not mounted')
707
+ return storage.open(domainSpec)
708
+ },
709
+ credentialRef: () => readCredentialRef(ctx),
710
+ resolveApiKey: async (ref) => {
711
+ const credentials = ctx.get('credentials')
712
+ if (credentials?.resolve !== undefined) {
713
+ const resolved = await credentials.resolve(ref)
714
+ if (resolved?.value) return resolved.value
715
+ }
716
+ return globalThis.process?.env?.[ref]
717
+ },
718
+ })
719
+
720
+ ctx.provide('capitalWatchlist', service)
721
+
722
+ // 预热一次只为了把"域打不开"写进启动日志。它**不设闩**:真正的响亮失败在每条路由的逐次
723
+ // catch 里(`store_unavailable`),而预热跑在装配期,此刻 `storageDomain` 完全可能还没
724
+ // provide——把这一次失败当结论钉住,就是"抢跑一次锁到进程结束"(见 `domain()`)。
725
+ service.list().catch((error) => {
726
+ ctx.logger?.error?.(`capital-watchlist: 域预热失败,下一次动作会重试(${storeCause(error)})`)
727
+ })
728
+
729
+ /**
730
+ * 认证围栏:解析 host 平面的 `connection`(web profile 由 `dsh-client-connection`
731
+ * 提供),把它的 `requestRejection` 接到本路由上——trusted-Host(403)+ 签名 cookie(401)。
732
+ * 惰性解析:`connection` 与本行的挂载先后不由我们决定。
733
+ */
734
+ const authorize = (req) => {
735
+ let connection
736
+ try {
737
+ connection = ctx.get('connection')
738
+ } catch {
739
+ return 401
740
+ }
741
+ if (connection === undefined || connection === null) return undefined
742
+ if (typeof connection.requestRejection !== 'function') return undefined
743
+ return connection.requestRejection(req)
744
+ }
745
+
746
+ const registerRoute = (webCtx) => {
747
+ webCtx.effect(
748
+ () => {
749
+ const dispose = webCtx.webServer.register({
750
+ kind: 'prefix',
751
+ path: ROUTE_PATH,
752
+ handler: createRouteHandler(service, { authorize }),
753
+ })
754
+ return () => {
755
+ if (typeof dispose === 'function') dispose()
756
+ }
757
+ },
758
+ 'capital-watchlist: route',
759
+ )
760
+ }
761
+ // 只走 inject:`ctx.get('webServer')` 有值不代表 `ctx.webServer` 属性可访问(cordis 只认
762
+ // "本层声明过 inject")。先探测再直接调用 = 把正确性押在装配顺序上,桌面端就是这么炸的
763
+ // (2026-09-30 实机,与 capital-charts 同一处形状)。
764
+ ctx.inject(['webServer'], registerRoute)
765
+
766
+ // 域是我们打开的,就得自己关(facility 只在自己卸载时兜底);同名重复 open 会 already-open。
767
+ ctx.effect(() => () => {
768
+ service.close().catch(() => {})
769
+ }, 'capital-watchlist: domain')
770
+
771
+ ctx.logger?.info?.('capital-watchlist: 自选股服务与 /capital-watchlist 路由已挂载')
772
+ }