@v587d/capital-generation 2.13.0 → 2.13.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/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@
7
7
  - **第三位(patch)**:不破坏既有工具 / 配置 / 会话的新增与修复(如 2.1.1、2.1.2)。
8
8
  - **第二位(minor)**:有需要用户知晓的行为变更,且段内附迁移说明(如 2.1.0 的图表呈现通道重做、2.2.0 的数据源与检索来源扩容、2.4.0 的**必须换宿主版本**、2.9.0 的选择器收敛与质检前置——本插件自己的工具与数据契约没变,所以只抬第二位)。
9
9
 
10
+ ## [2.13.2] - 2026-10-29
11
+
12
+ 投研自动化任务详情与选择器的一轮可用性收敛:去掉重复信息、统一运行入口,并让下拉与宿主设置一致。
13
+ 只改客户端呈现,API、状态机与存储契约不变。
14
+
15
+ ### Changed
16
+
17
+ - **任务详情收敛**:头部不再重复分类 / 日程摘要;概览四张卡按未添加、暂停、运行中、失败、跳过
18
+ 区分,未添加或暂停不再展示不会触发的下次运行日期;删除重复的整块「日程」;「最近 3 次运行」
19
+ 只保留摘要,链接「完整记录」留在本任务运行记录页签;错误条数移到「工具调用」之后,右侧改为
20
+ 「打开 session」主按钮并带外链图标;运行中保留「取消运行」。
21
+ - **执行环境去内部化**:运行模式只显示「自动模式」,不再暴露 `capital-generation` 内部 preset id;
22
+ 概览移除执行策略长文与预算子树说明;技术信息直接展开、与上方信息用分隔线隔开,去掉版本号,
23
+ 并补充 profileKey / 工作区来源的说明。
24
+ - **下拉统一为宿主设置同款**:投研自动化的触发频率、每月日期、工作区、分类、输出侧重等 8 个原生
25
+ 下拉全部改为 DSH 通用设置的填充胶囊按钮 + 官方 Menu,支持选中勾选、键盘走位、Esc / 点外部关闭
26
+ 和 portal 浮层,避免被弹窗裁切。
27
+
28
+ ## [2.13.1] - 2026-10-28
29
+
30
+ 投研自动化补上一轮可用性与可观测性:任务页和运行历史重做,交易日历改用深交所官方休市日历;
31
+ 进程重启或插件卸载后,已结束的回合不再被误记为未知。另修正 Wind EDB 的取数指引。
32
+
33
+ ### Changed
34
+
35
+ - **投研自动化页面重做**:任务页增加最近 30 天运行历史、运行中任务、分类筛选与搜索;模板区、
36
+ 任务详情、运行页和通知中心统一版式。首次打开先显示与最终布局同轮廓的骨架屏,不再先闪
37
+ 「还没有任务」;模板用「添加」,与「立即运行」分开。
38
+ - **交易日历改为深交所官方休市日历(无需 API Key)**:按月拉取并缓存
39
+ `<dsh home>/storages/capital-automation-calendar/szse.json`;已公布月份覆盖内置表,未公布月份
40
+ 回退内置表,覆盖不足 30 天时页面提前告警。交易所公布新一年安排后自动接上,不再需要改代码补表;
41
+ `tradingDaysOnly` 的 fail-closed 语义与 occurrence 计算不变。
42
+ - **Wind EDB 取数改为中文问句优先**:已知指标直接走 `wind_edb_query` 的中文问句,不再要求先
43
+ `wind_edb_search` 拿 code;两个能力都按次消耗上游积分,检索只用于不确定指标名或需要候选清单时。
44
+
45
+ ### Fixed
46
+
47
+ - **重启或 runtime 卸载把已完成回合误记为未知**:`turn/start` / `turn/end` 现在把回合边界写入
48
+ `turnState`;恢复时按已结束回合的原始终态收口,只有没有回合边界的运行才记
49
+ `unknown_after_restart`,并继续保持不自动重试。
50
+ - **日历刷新在途时提前烧掉当天 occurrence**:启动先等一次日历就绪,刷新未完成时顺延到下一 tick
51
+ 再判定,不把慢请求误判为「日历不可用」并跳过当天任务。
52
+
10
53
  ## [2.13.0] - 2026-10-27
11
54
 
12
55
  新增 CapGen 投研自动化,目前还在实验阶段,存在诸多不稳定。
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  <p align="center">
19
19
  <a href="https://awesome-dsh-plugin.com"><img src="https://awesome-dsh-plugin.com/badge.svg" alt="awesome · DSH plugin"></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="License"></a>
21
- <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.13.0-9cf" alt="Version"></a>
21
+ <a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.13.2-9cf" alt="Version"></a>
22
22
  <a href="https://www.npmjs.com/package/@v587d/capital-generation"><img src="https://img.shields.io/npm/v/%40v587d%2Fcapital-generation" alt="npm version"></a>
23
23
  <a href="https://github.com/deepseek-ai/deepseek-harness"><img src="https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white" alt="powered-by-dsh"></a>
24
24
  </p>
@@ -34,97 +34,77 @@ Capital Generation 是运行在 [DeepSeek Harness(DSH)](https://github.com/d
34
34
  > [!IMPORTANT]
35
35
  > 本项目仍在探索中,不提供代交易服务,不保证数据完整、及时或准确,也不承诺投资回报。请自行核对来源并承担投资风险。
36
36
 
37
+ ## 「🎉 CapGen 投研自动化」 已上线,欢迎试用并提 issue 💕 [快速了解](#capgen-automation)
38
+
37
39
  ## 快速上手
38
40
 
39
41
  > [!NOTE]
40
- > 适配 DSH`@0.2.1-rc.3`。已在 Linux 的 Web profile 和 Windows 桌面端验证;其他环境尚未充分测试。建议使用 `Deepseek/deepseek-flash` ,思考推理能力为 High ;GPT / Claude 尚未充分测试。
41
-
42
- > [!TIP]
43
- > **建议用独立 profile 启动。** 安装到 `capital` 后,用下面的命令打开一个全新的 profile:
42
+ > 最新版本已适配 DSH`@0.2.1-rc.3`。
44
43
 
45
- > ```bash
46
- > dsh --profile capital --port 8888
47
- > ```
48
- >
49
- > 然后访问 `http://localhost:8888`。本插件、密钥和默认模式都只属于这个 profile,不会和 DSH web profile 的默认设置冲突。
50
44
 
51
- 1. **安装插件。**
45
+ 0. **前置**
52
46
 
53
- Linux / macOS 的 Web 或 CLI profile 在终端执行,下面两条 **任选一条**。建议装进独立 profile(例如 `capital`),避免和默认 `web` profile 里的插件、密钥与预设互相影响:
47
+ **建议用独立 profile 启动。** 比如:
48
+
49
+ ```bash
50
+ dsh --profile capital --port 8888
51
+ ```
52
+ 然后访问 `http://localhost:8888`。这样,本插件只属于 capital 这个 profile,避免和其他 profile 冲突。
54
53
 
55
- ```bash
56
- dsh plugin --profile capital add @v587d/capital-generation
57
- ```
58
- 或
59
- ```bash
60
- dsh plugin --profile capital add github:v587d/capital-generation
61
- ```
54
+ 1. **安装插件**
62
55
 
63
- 两条装完的功能一样:前者取 npm 上已发布的版本,后者取本仓库当前的代码。`capital` 会在第一次执行命令时自动初始化;装好后按下一步启动。
56
+ Linux / macOS :
64
57
 
65
- DSH 桌面端在 「添加插件」 中输入,下面两条 **任选一条**:
58
+ ```bash
59
+ dsh plugin --profile capital add @v587d/capital-generation
60
+ ```
61
+ 或
62
+ ```bash
63
+ dsh plugin --profile capital add github:v587d/capital-generation
64
+ ```
66
65
 
67
- ```input
68
- https://github.com/v587d/capital-generation
69
- ```
70
- 或
71
- ```input
72
- @v587d/capital-generation
73
- ```
74
- 桌面端自带 DSH 宿主,不需要单独用 npm 安装宿主。本仓库已包含构建产物,普通用户不需要克隆仓库或运行构建命令。
66
+ DSH 桌面端在 「添加插件」 中输入:
75
67
 
68
+ ```input
69
+ https://github.com/v587d/capital-generation
70
+ ```
71
+ 或
72
+ ```input
73
+ @v587d/capital-generation
74
+ ```
75
+ **本仓库已包含构建产物,普通用户不需要克隆仓库或运行构建命令。**
76
76
 
77
- > [!NOTE]
78
- > 本插件所有自带密钥均可免费申请、免费日常使用。 哪怕所有密钥留空也不影响插件核心功能,即缺哪个密钥,仅表示对应能力就不可用,其他功能照常。
77
+ 2. **选填密钥** (打开 DSH 「插件」 → **Capital Generation** →「数据源」)
78
+
79
+ 本插件所有自带密钥均可免费申请、免费日常使用。哪怕所有密钥留空也不影响插件核心功能,即缺哪个密钥,仅表示对应能力就不可用,其他功能照常。
80
+
81
+ | 密钥 | 用途 |
82
+ | --- | --- |
83
+ | [同花顺 Fuyao](https://fuyao.aicubes.cn/docs/) | 行情、财务数据和自选股报价 |
84
+ | [AnySearch](https://www.anysearch.com/docs) | 网页搜索和正文提取 |
85
+ | [Wind Alice](https://market.windalice.com/#/home) | 公告与新闻检索,以及宏观/行业/汇率指标与按日期区间取的历史 K 线 |
86
+ | [PaddleOCR AIStudio](https://aistudio.baidu.com/paddleocr) | 解析 PDF 和图片中的文字 |
87
+ | [FTShare(非凸科技)](https://market.ft.tech/gateway/doc/p/pb8eizu3) | A 股行情与日 K 截面、主力资金流、涨跌停、龙虎榜席位、股东人数、指数权重、可转债行情、宏观利率,以及期货合约与贵金属行情等第二家上游口径 |
79
88
 
80
- 2. **选填密钥。** 打开 DSH 「插件」 → **Capital Generation** →「数据源」(进页面就停在这一格)。
89
+ 第三方服务的密钥,需分别向服务方申请;额度和条款以各服务方为准。保存后 **新建会话** 即可生效,无需重启。
81
90
 
82
- | 密钥 | 用途 |
83
- | --- | --- |
84
- | [同花顺 Fuyao](https://fuyao.aicubes.cn/docs/) | 行情、财务数据和自选股报价 |
85
- | [AnySearch](https://www.anysearch.com/docs) | 网页搜索和正文提取 |
86
- | [Wind Alice](https://market.windalice.com/#/home) | 公告与新闻检索,以及宏观/行业/汇率指标与按日期区间取的历史 K 线 |
87
- | [PaddleOCR AIStudio](https://aistudio.baidu.com/paddleocr) | 解析 PDF 和图片中的文字 |
88
- | [FTShare(非凸科技)](https://market.ft.tech/gateway/doc/p/pb8eizu3) | A 股行情与日 K 截面、主力资金流、涨跌停、龙虎榜席位、股东人数、指数权重、可转债行情、宏观利率,以及期货合约与贵金属行情等第二家上游口径 |
91
+ 也可以直接把密钥写入`~/.dsh/.credentials.yaml`,名称依次为 `FUYAO_API_KEY`、`ANYSEARCH_API_KEY`、`WIND_API_KEY`、`PADDLE_OCR_TOKEN`、`FTSHARE_API_KEY`。
89
92
 
90
- 这些都是第三方服务的密钥,需分别向服务方申请;额度和条款以各服务方为准。
91
- 保存后**新建会话**即可生效,无需重启。
92
- 也可以不走卡片,直接把密钥写入 `~/.dsh/.credentials.yaml`,名称依次为 `FUYAO_API_KEY`、`ANYSEARCH_API_KEY`、`WIND_API_KEY`、`PADDLE_OCR_TOKEN`、`FTSHARE_API_KEY`。
93
- 密钥由 DSH 凭据服务保存,不会显示在会话配置中。
94
-
95
- <p align="center">
96
- <a href="assets/数据源配置.png">
97
- <img src="assets/数据源配置.png" alt="数据源配置" width="800">
98
- </a>
99
- </p>
93
+ 密钥由 DSH 凭据服务保存。
100
94
 
101
95
  3. **新建会话。** 新会话的模式选择器里会看到三个自定义模式。日常研究选 **自动模式**,然后直接描述研究问题;也可以到「设置」→「Agent 预设」把它设为默认模式。
102
96
 
103
- <p align="center">
104
- <a href="assets/session-preset-selector.png">
105
- <img src="assets/session-preset-selector.png" alt="新会话选择自动模式" width="800">
106
- </a>
107
- </p>
108
-
109
- ## 三个模式怎么选
110
-
111
- <p align="center">
112
- <a href="assets/settings-perset-selector.png">
113
- <img src="assets/settings-perset-selector.png" alt="「Agent 预设」里的三个自定义模式" width="760">
114
- </a>
115
- </p>
116
-
117
- | 模式 | 适合什么 | 会做什么 | 边界 |
118
- | --- | --- | --- | --- |
119
- | **自动模式**(推荐) | 投研报告、事件影响、持仓复盘,以及需要同时查数、查资料和出图的综合问题 | 统一调度取数、数据质检、资料检索与绘图,汇总证据后直接答复 | 自动模式不直接取数,也不直接画图;每个数字都要有来源与截止时间 |
120
- | **行情专家** | 只想拿一份结构化行情、财务或宏观数据 | 查询并保存到当前会话,返回数据引用、来源与截止时间 | 只负责取数,不读取数据内容、不做判断;数据只在当前会话可用 |
121
- | **资讯专员** | 只要公告、新闻或互动平台材料 | 检索公告与新闻,逐条标注来源与核验状态 | 不做投资分析,也不给买卖建议 |
97
+ | 模式 | 适合什么 | 会做什么 | 边界 |
98
+ | --- | --- | --- | --- |
99
+ | **自动模式**(推荐) | 投研报告、事件影响、持仓复盘,以及需要同时查数、查资料和出图的综合问题 | 统一调度取数、数据质检、资料检索与绘图,汇总证据后直接答复 | 自动模式不直接取数,也不直接画图;每个数字都要有来源与截止时间 |
100
+ | **行情专家** | 只想拿一份结构化行情、财务或宏观数据 | 查询并保存到当前会话,返回数据引用、来源与截止时间 | 只负责取数,不读取数据内容、不做判断;数据只在当前会话可用 |
101
+ | **资讯专员** | 只要公告、新闻或互动平台材料 | 检索公告与新闻,逐条标注来源与核验状态 | 不做投资分析,也不给买卖建议 |
122
102
 
123
- **分析师**与**绘图专员**是自动模式的内部角色,不出现在选择器里。自动模式会在需要时创建并调度它们;它们没有独立的取数入口,单独打开也读不到本次研究的数据。
103
+ **分析师**与**绘图专员**是自动模式的内部角色,不出现在选择器里。自动模式会在需要时创建并调度它们;它们没有独立的取数入口,单独打开也读不到本次研究的数据。
124
104
 
125
- **每份结构化数据都会先做质检。** 无论你有没有提图表,自动模式收到行情或财务数据后都会先交给分析师检查结构、时间轴、缺失与基础统计;要不要图是检查之后的一个判断。明确说“不要图”时,质检照做,只是停在出图之前。
105
+ **每份结构化数据都会先做质检。** 无论你有没有提图表,自动模式收到行情或财务数据后都会先交给分析师检查结构、时间轴、缺失与基础统计;要不要图是检查之后的一个判断。明确说“不要图”时,质检照做,只是停在出图之前。
126
106
 
127
- 自动模式会把子角色工作期间的中间回执留在同一轮里处理,等材料收齐后再给出完整结论,而不是每条中间进展都单独回一段。
107
+ 自动模式会把子角色工作期间的中间回执留在同一轮里处理,等材料收齐后再给出完整结论,而不是每条中间进展都单独回一段。
128
108
 
129
109
  ## 试试这么用
130
110
 
@@ -132,9 +112,12 @@ https://github.com/v587d/capital-generation
132
112
 
133
113
  [样例:指南针、同花顺复盘分析报告(20261023)](docs/sample/指南针-同花顺-复盘分析报告.md)
134
114
 
115
+ <details>
116
+ <summary>展开示例指令</summary>
117
+
135
118
  **完整投资研究报告**
136
119
 
137
- ```text
120
+ ```instruction
138
121
  请对[公司名称、俗称]([证券代码])做一份完整投资研究报告。
139
122
  以你实际取得的最新可用数据为截止,覆盖业务、财务、估值、价格走势、近期重要事件以及风险与反方观点。
140
123
  先给核心判断和最大风险;关键数字注明报告期或交易日、来源和采集时间。
@@ -143,7 +126,7 @@ https://github.com/v587d/capital-generation
143
126
 
144
127
  **事件与公告影响分析**
145
128
 
146
- ```text
129
+ ```instruction
147
130
  请核查[公司名称]([完整证券代码])的[公告标题及披露日期,或“最近一次业绩公告”],
148
131
  分析它对经营、现金流和投资风险可能产生的影响。
149
132
  先核对公告原文、披露时间和事项进展,区分已实施事实与尚待批准的方案;
@@ -153,7 +136,7 @@ https://github.com/v587d/capital-generation
153
136
 
154
137
  **持仓与组合复盘**
155
138
 
156
- ```text
139
+ ```instruction
157
140
  请复盘我的持仓[其他资产如有,请补充]。
158
141
  请先核对这些比例是否覆盖全部资产,再分析单只标的、行业与市场的集中风险,
159
142
  以及哪些风险会同时影响多个持仓。
@@ -162,6 +145,8 @@ https://github.com/v587d/capital-generation
162
145
  给出最需要核实的信息和可观察的防守选项。
163
146
  ```
164
147
 
148
+ </details>
149
+
165
150
  ## 还能做什么
166
151
 
167
152
  - **查数据和资料:** 覆盖 A 股、港股、美股与期货等品种,由专用角色按需获取行情、财务、指数、基金、宏观行业与期货数据,检索公告、新闻和研报。共计 **128** 个数据收集能力和 **14** 个网页检索能力,部分公开数据源无需额外密钥;具体覆盖范围见 [数据收集能力表](docs/data-collector-capabilities.md) 和 [网页检索能力表](docs/web-retriever-capabilities.md)。
@@ -172,7 +157,7 @@ https://github.com/v587d/capital-generation
172
157
  | :---: | :---: |
173
158
  | <a href="assets/chart-line-sidebar.png"><img src="assets/chart-line-sidebar.png" alt="侧栏折线图" width="400"></a> | <a href="assets/chart-candlestick-sidebar.png"><img src="assets/chart-candlestick-sidebar.png" alt="侧栏 K 线图" width="400"></a> |
174
159
 
175
- - **管理自选股:** 在任意 Capital Generation 会话中输入 `/` 或点击输入框旁的 `+`,从「指令」中打开「自选股」。
160
+ - **管理自选股:** 在 **自动模式、市场专家、资讯专家** 三种模式的会话中输入 `/` 或点击输入框旁的 `+`,从「指令」中打开「自选股」。
176
161
 
177
162
  | 管理自选股 | 标记持仓 |
178
163
  | :---: | :---: |
@@ -180,6 +165,12 @@ https://github.com/v587d/capital-generation
180
165
 
181
166
  - **精选 Skills:** 当前收录 8 个开源项目、共 70 个 Skill。默认不开启「智能装载 Skills」,逐项清单和勾选都在;打开后清单收起,按角色开放该角色全部方法,由模型按任务挑 1–2 条,运行期不再看勾选。两种状态都按角色分发:研究/组合方法归自动模式,估值/财务/风险/筛选归分析师,文档深读与核验归资讯专员;无需新建会话,下一轮就生效。
182
167
 
168
+ <p align="center">
169
+ <a href="assets/selected-skills配置.png">
170
+ <img src="assets/selected-skills配置.png" alt="精选 Skills 配置:按 Skill 特点与适用预设筛选" width="800">
171
+ </a>
172
+ </p>
173
+
183
174
  | 项目名称 | 收录边界 |
184
175
  | :---: | :---: |
185
176
  | [The Investment Skills Lexicon](https://github.com/finterm-ai/investment-skills) | Buffett Investment Framework |
@@ -191,9 +182,17 @@ https://github.com/v587d/capital-generation
191
182
  | [FinanceSkills](https://github.com/GAJETOso/financeskills) | 8 个估值与财务方法 Skill:折现率(WACC)估算、三表勾稽建模、企业估值与股权价值桥、偿债能力与债务容量、预期信用损失三阶段、银行资本与流动性、保险准备金与综合成本率、账务异常排查;按 IFRS / 美股与国际准则口径写,境内监管阈值与科目名需自行对照 |
192
183
  | [Quant Agent Skills](https://github.com/Howard-Jerry/quant-agent-skills) | 1 个跨市场选线 Skill:同一家公司在 A 股与港股、或美股 ADR 与港股各有一条线时该走哪一条——价差、币种与股息税、流动性与通道、披露文件路径各不相同。公司本身只研究一次,便宜的那条线不构成"这家公司更值得买";A/H 溢价与 ADR 平价没有现成条目,须按两条线同一天的收盘价与当日汇率现算,缺任一项就只报缺的那一项 |
193
184
 
185
+ <a id="capgen-automation" name="capgen-automation"></a>
186
+ - **CapGen 投研自动化:** 内置早间资讯、盘后公告与每日复盘三个模板,也可新建自己的任务;到点自动新建 session 执行,也能手动运行。任务、运行历史与失败通知在同一页,支持运行预算、通知和仅交易日触发。
187
+
194
188
  <p align="center">
195
- <a href="assets/selected-skills配置.png">
196
- <img src="assets/selected-skills配置.png" alt="精选 Skills 配置:按 Skill 特点与适用预设筛选" width="800">
189
+ <a href="assets/capgen-automation-tasks.png">
190
+ <img src="assets/capgen-automation-tasks.png" alt="投研自动化:任务、日程与运行概览" width="800">
191
+ </a>
192
+ </p>
193
+ <p align="center">
194
+ <a href="assets/capgen-automation-runs.png">
195
+ <img src="assets/capgen-automation-runs.png" alt="投研自动化:运行历史与错误详情" width="800">
197
196
  </a>
198
197
  </p>
199
198
 
@@ -202,7 +201,7 @@ https://github.com/v587d/capital-generation
202
201
  - **安装后找不到模式?** 确认插件出现在「插件」→ 已安装列表;Web / CLI profile 安装后要重启该 profile,并在新会话选择模式。选择器里应看到自动模式、行情专家、资讯专员;分析师和绘图专员是自动模式的内部角色,不会出现在这里。
203
202
  - **配置在哪?** 打开「插件」→ 已安装的 **Capital Generation**,页面顶部就是「数据源 / 精选 Skills / 包含的组件」三格,五把密钥都在第一格;不在 DSH 的普通「设置」页面,也不用点进任何一行组件。保存密钥后新建会话再试。
204
203
  - **升级到 2.9.0 后,分析师和绘图专员去哪了?** 这一版把它们收进自动模式内部,不再出现在选择器与预设管理里,避免误开一个没有取数入口的空会话。旧会话仍能按原身份恢复;新任务直接用自动模式提出即可。
205
- - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.13.0` 或 `@2.13.0`。
204
+ - **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.13.2` 或 `@2.13.2`。
206
205
  - **刚发布的新版本装不到?** 桌面端取包时会跳过**刚发布不久**的版本(宿主侧包管理器的发布冷却策略,实测会静默装成上一个版本),国内镜像的同步也可能滞后一段时间。急着用最新代码,改用仓库地址安装最直接。
207
206
  - **网页抓取失败?** 配置卡片中的「允许启动本地提取网页内容」默认开启:AnySearch 提取失败时可尝试本机直连。关闭后,本机直连的具名来源查询也不可用;请检查该开关和网络连接。
208
207
  - **Wind 那部分会消耗积分?** 会。公告与新闻检索、宏观 / 行业 / 汇率指标、按日期区间取的历史 K 线都按次消耗你在 Wind 侧的额度;额度与条款以 Wind 为准,插件不做用量封顶。没配 `WIND_API_KEY` 时这几类不可用,其余功能照常。
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Scheduler calendar backed by the exchange's published closure calendar (SSE/SZSE share it),
3
+ * fetched from SZSE's keyless month JSON. Fetched months win over the bundled table; an
4
+ * unpublished month falls back to it, and an uncovered date stays `undefined` (fail-closed).
5
+ */
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
7
+ import { dirname, join } from 'node:path'
8
+ import { createTradingCalendar } from './calendar.js'
9
+ import { sanitizeMessage } from './redact.js'
10
+
11
+ const SZSE_MONTH_URL = 'https://www.szse.cn/api/report/exchange/onepersistenthour/monthList'
12
+ const CACHE_VERSION = 1
13
+ const DEFAULT_TIMEOUT_MS = 8_000
14
+ const DEFAULT_REFRESH_MS = 24 * 60 * 60_000
15
+ const FAILURE_RETRY_MS = 30 * 60_000
16
+ const UNPUBLISHED_RETRY_MS = 6 * 60 * 60_000
17
+ const INITIAL_WAIT_MS = 5_000
18
+ const DEFAULT_HORIZON_DAYS = 400
19
+ const BACKFILL_DAYS = 62
20
+ const CONCURRENCY = 4
21
+ const MAX_BODY_CHARS = 256 * 1024
22
+ const OFFSET_SHANGHAI_MS = 8 * 60 * 60_000
23
+ const MONTH_PATTERN = /^\d{4}-(?:0[1-9]|1[0-2])$/
24
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
25
+ const USER_AGENT = '@v587d/capital-generation'
26
+
27
+ function shanghaiDate(instant) {
28
+ return new Date(instant + OFFSET_SHANGHAI_MS).toISOString().slice(0, 10)
29
+ }
30
+
31
+ function monthOf(date) {
32
+ return date.slice(0, 7)
33
+ }
34
+
35
+ function daysInMonth(month) {
36
+ const [year, value] = month.split('-').map(Number)
37
+ return new Date(Date.UTC(year, value, 0)).getUTCDate()
38
+ }
39
+
40
+ function addDays(date, delta) {
41
+ const [year, month, day] = date.split('-').map(Number)
42
+ const next = new Date(Date.UTC(year, month - 1, day + delta))
43
+ return next.toISOString().slice(0, 10)
44
+ }
45
+
46
+ function monthRange(from, to) {
47
+ const months = []
48
+ let [year, month] = from.split('-').map(Number)
49
+ const [lastYear, lastMonth] = to.split('-').map(Number)
50
+ while (year < lastYear || (year === lastYear && month <= lastMonth)) {
51
+ months.push(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}`)
52
+ month += 1
53
+ if (month > 12) {
54
+ month = 1
55
+ year += 1
56
+ }
57
+ }
58
+ return months
59
+ }
60
+
61
+ function delay(ms) {
62
+ return new Promise((resolve) => {
63
+ const timer = setTimeout(resolve, ms)
64
+ timer.unref?.()
65
+ })
66
+ }
67
+
68
+ /** @returns {{kind:'ok',days:Map<string,boolean>}|{kind:'unpublished'}|{kind:'invalid',reason:string}} */
69
+ function parseMonth(month, body) {
70
+ if (body === null || typeof body !== 'object' || !Array.isArray(body.data)) {
71
+ return { kind: 'invalid', reason: 'response shape' }
72
+ }
73
+ const rows = body.data
74
+ if (rows.length === 0) return { kind: 'unpublished' }
75
+ if (rows.length !== daysInMonth(month)) return { kind: 'invalid', reason: `expected ${daysInMonth(month)} rows, got ${rows.length}` }
76
+ const days = new Map()
77
+ let trading = 0
78
+ for (const row of rows) {
79
+ const date = row?.jyrq
80
+ if (typeof date !== 'string' || !DATE_PATTERN.test(date) || monthOf(date) !== month) {
81
+ return { kind: 'invalid', reason: 'row date does not match month' }
82
+ }
83
+ if (days.has(date)) return { kind: 'invalid', reason: 'duplicate row' }
84
+ const flag = row?.jybz
85
+ if (flag !== '0' && flag !== '1') return { kind: 'invalid', reason: 'row flag is not 0/1' }
86
+ days.set(date, flag === '1')
87
+ if (flag === '1') trading += 1
88
+ }
89
+ if (trading === 0) return { kind: 'invalid', reason: 'no trading day in month' }
90
+ return { kind: 'ok', days }
91
+ }
92
+
93
+ async function fetchMonth(month, options) {
94
+ const { fetchImpl, timeoutMs, signal } = options
95
+ const timeout = AbortSignal.timeout(timeoutMs)
96
+ const combined = signal !== undefined && typeof AbortSignal.any === 'function'
97
+ ? AbortSignal.any([signal, timeout])
98
+ : timeout
99
+ const response = await fetchImpl(`${SZSE_MONTH_URL}?month=${encodeURIComponent(month)}`, {
100
+ headers: { accept: 'application/json', 'user-agent': USER_AGENT },
101
+ signal: combined,
102
+ })
103
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
104
+ const type = response.headers?.get?.('content-type') ?? ''
105
+ if (!type.includes('json')) throw new Error(`unexpected content-type ${type || '(none)'}`)
106
+ const text = await response.text()
107
+ if (text.length > MAX_BODY_CHARS) throw new Error('response too large')
108
+ return parseMonth(month, JSON.parse(text))
109
+ }
110
+
111
+ async function mapLimit(items, limit, work) {
112
+ const results = new Array(items.length)
113
+ let index = 0
114
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
115
+ while (index < items.length) {
116
+ const current = index
117
+ index += 1
118
+ results[current] = await work(items[current])
119
+ }
120
+ })
121
+ await Promise.all(workers)
122
+ return results
123
+ }
124
+
125
+ /**
126
+ * @param {object} [options]
127
+ * @param {typeof fetch} [options.fetch]
128
+ * @param {() => number} [options.now]
129
+ * @param {string} [options.cacheFile]
130
+ * @param {string} [options.cacheDir]
131
+ * @param {ReturnType<typeof createTradingCalendar>} [options.fallback]
132
+ * @param {{warn?: Function, info?: Function}} [options.logger]
133
+ */
134
+ export function createCalendarSource(options = {}) {
135
+ const fetchImpl = options.fetch ?? ((...args) => globalThis.fetch(...args))
136
+ const now = options.now ?? (() => Date.now())
137
+ const fallback = options.fallback ?? createTradingCalendar()
138
+ const logger = options.logger
139
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
140
+ const refreshMs = options.refreshMs ?? DEFAULT_REFRESH_MS
141
+ const horizonDays = options.horizonDays ?? DEFAULT_HORIZON_DAYS
142
+ const cacheFile = options.cacheFile ?? (options.cacheDir === undefined ? undefined : join(options.cacheDir, 'szse.json'))
143
+
144
+ /** @type {Map<string, {days: Map<string, boolean>, fetchedAt: number}>} */
145
+ const remote = new Map()
146
+ let loaded = false
147
+ let loading
148
+ let refreshing
149
+ let lastAttemptAt = 0
150
+ let lastError = null
151
+ let disposed = false
152
+ let controller
153
+
154
+ function targetMonths() {
155
+ const today = shanghaiDate(now())
156
+ return monthRange(monthOf(addDays(today, -BACKFILL_DAYS)), monthOf(addDays(today, horizonDays)))
157
+ }
158
+
159
+ function isTradingDay(date) {
160
+ if (typeof date !== 'string' || !DATE_PATTERN.test(date)) return undefined
161
+ const entry = remote.get(monthOf(date))
162
+ if (entry !== undefined && entry.days.size > 0) {
163
+ if (entry.days.has(date)) return entry.days.get(date) === true
164
+ }
165
+ return fallback.isTradingDay(date)
166
+ }
167
+
168
+ function fetchedAtOf() {
169
+ let latest
170
+ for (const entry of remote.values()) {
171
+ if (latest === undefined || entry.fetchedAt > latest) latest = entry.fetchedAt
172
+ }
173
+ return latest
174
+ }
175
+
176
+ function status(at = new Date(now())) {
177
+ const instant = at instanceof Date ? at.getTime() : Number(at)
178
+ const today = shanghaiDate(Number.isFinite(instant) ? instant : now())
179
+ const todayKnown = isTradingDay(today) !== undefined
180
+ let through = null
181
+ let firstUnknown = null
182
+ let cursor = today
183
+ for (let index = 0; index < 500; index += 1) {
184
+ if (isTradingDay(cursor) === undefined) {
185
+ firstUnknown = cursor
186
+ break
187
+ }
188
+ through = cursor
189
+ cursor = addDays(cursor, 1)
190
+ }
191
+ const entry = remote.get(monthOf(today))
192
+ const source = entry !== undefined && entry.days.has(today) ? 'szse' : todayKnown ? 'bundled' : 'none'
193
+ const fetchedAt = fetchedAtOf()
194
+ const coverageDays = through === null
195
+ ? 0
196
+ : Math.max(0, Math.round((Date.parse(`${through}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000))
197
+ return {
198
+ available: todayKnown,
199
+ source,
200
+ through,
201
+ firstUnknown,
202
+ coverageDays,
203
+ fetchedAt: fetchedAt === undefined ? null : new Date(fetchedAt).toISOString(),
204
+ stale: fetchedAt !== undefined && now() - fetchedAt > refreshMs * 2,
205
+ refreshing: refreshing !== undefined,
206
+ lastError: lastError === null ? null : lastError,
207
+ }
208
+ }
209
+
210
+ async function load() {
211
+ if (loading !== undefined) return loading
212
+ if (loaded) return
213
+ loaded = true
214
+ if (cacheFile === undefined) return
215
+ loading = (async () => {
216
+ let text
217
+ try {
218
+ text = await readFile(cacheFile, 'utf8')
219
+ } catch (error) {
220
+ if (error?.code !== 'ENOENT') logger?.warn?.(`capital-automation: 交易日历缓存读取失败:${sanitizeMessage(String(error))}`)
221
+ return
222
+ }
223
+ let data
224
+ try {
225
+ data = JSON.parse(text)
226
+ } catch {
227
+ return
228
+ }
229
+ if (data?.version !== CACHE_VERSION || data.months === null || typeof data.months !== 'object') return
230
+ for (const [month, entry] of Object.entries(data.months)) {
231
+ if (!MONTH_PATTERN.test(month) || entry === null || typeof entry !== 'object') continue
232
+ const fetchedAt = Date.parse(entry.fetchedAt)
233
+ if (!Number.isFinite(fetchedAt)) continue
234
+ const days = new Map()
235
+ const rows = entry.days !== null && typeof entry.days === 'object' ? entry.days : {}
236
+ for (const [date, flag] of Object.entries(rows)) {
237
+ if (!DATE_PATTERN.test(date) || monthOf(date) !== month) continue
238
+ if (flag !== 0 && flag !== 1) continue
239
+ days.set(date, flag === 1)
240
+ }
241
+ if (days.size !== 0 && days.size !== daysInMonth(month)) continue
242
+ remote.set(month, { days, fetchedAt })
243
+ }
244
+ })().finally(() => { loading = undefined })
245
+ return loading
246
+ }
247
+
248
+ async function writeCache() {
249
+ if (cacheFile === undefined || disposed) return
250
+ const months = {}
251
+ for (const [month, entry] of remote) {
252
+ months[month] = {
253
+ fetchedAt: new Date(entry.fetchedAt).toISOString(),
254
+ days: Object.fromEntries([...entry.days].map(([date, trading]) => [date, trading ? 1 : 0])),
255
+ }
256
+ }
257
+ const temporary = `${cacheFile}.${process.pid}.tmp`
258
+ try {
259
+ await mkdir(dirname(cacheFile), { recursive: true })
260
+ await writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, savedAt: new Date(now()).toISOString(), months }), 'utf8')
261
+ await rename(temporary, cacheFile)
262
+ } catch (error) {
263
+ logger?.warn?.(`capital-automation: 交易日历缓存写入失败:${sanitizeMessage(String(error))}`)
264
+ }
265
+ }
266
+
267
+ async function refresh({ force = false } = {}) {
268
+ if (disposed) return status()
269
+ if (refreshing !== undefined) return refreshing
270
+ refreshing = (async () => {
271
+ await load()
272
+ if (disposed) return status()
273
+ const stamp = now()
274
+ const pending = targetMonths().filter((month) => {
275
+ const entry = remote.get(month)
276
+ if (force) return true
277
+ if (entry === undefined) return true
278
+ const age = stamp - entry.fetchedAt
279
+ return entry.days.size === 0 ? age >= UNPUBLISHED_RETRY_MS : age >= refreshMs
280
+ })
281
+ let changed = false
282
+ let failures = 0
283
+ if (pending.length > 0) {
284
+ controller = new AbortController()
285
+ const signal = controller.signal
286
+ const results = await mapLimit(pending, CONCURRENCY, async (month) => {
287
+ try {
288
+ return { month, parsed: await fetchMonth(month, { fetchImpl, timeoutMs, signal }) }
289
+ } catch (error) {
290
+ return { month, error }
291
+ }
292
+ })
293
+ if (disposed) return status()
294
+ for (const result of results) {
295
+ if (result.error !== undefined) {
296
+ failures += 1
297
+ lastError = `${result.month}: ${sanitizeMessage(String(result.error?.message ?? result.error))}`
298
+ continue
299
+ }
300
+ if (result.parsed.kind === 'ok') {
301
+ remote.set(result.month, { days: result.parsed.days, fetchedAt: stamp })
302
+ changed = true
303
+ } else if (result.parsed.kind === 'unpublished') {
304
+ remote.set(result.month, { days: new Map(), fetchedAt: stamp })
305
+ changed = true
306
+ } else {
307
+ failures += 1
308
+ lastError = `${result.month}: ${result.parsed.reason}`
309
+ }
310
+ }
311
+ if (failures === 0) lastError = null
312
+ } else {
313
+ lastError = null
314
+ }
315
+ lastAttemptAt = now()
316
+ if (changed) {
317
+ await writeCache()
318
+ const snapshot = status()
319
+ logger?.info?.(`capital-automation: 交易日历已刷新(${snapshot.source},覆盖至 ${snapshot.through ?? '未知'})`)
320
+ }
321
+ return status()
322
+ })().finally(() => { refreshing = undefined })
323
+ return refreshing
324
+ }
325
+
326
+ function refreshIfStale() {
327
+ if (disposed || refreshing !== undefined) return
328
+ const stamp = now()
329
+ const interval = lastError === null ? refreshMs : FAILURE_RETRY_MS
330
+ const due = lastAttemptAt === 0 || stamp - lastAttemptAt >= interval
331
+ if (!due) {
332
+ const retry = targetMonths().some((month) => {
333
+ const entry = remote.get(month)
334
+ return entry !== undefined && entry.days.size === 0 && stamp - entry.fetchedAt >= UNPUBLISHED_RETRY_MS
335
+ })
336
+ if (!retry) return
337
+ }
338
+ void refresh().catch(() => {})
339
+ }
340
+
341
+ async function ready() {
342
+ await load()
343
+ const today = shanghaiDate(now())
344
+ if (isTradingDay(today) !== undefined) {
345
+ void refresh().catch(() => {})
346
+ return
347
+ }
348
+ await Promise.race([refresh().catch(() => {}), delay(INITIAL_WAIT_MS)])
349
+ }
350
+
351
+ function pendingRefresh() {
352
+ return refreshing !== undefined
353
+ }
354
+
355
+ function dispose() {
356
+ disposed = true
357
+ controller?.abort()
358
+ }
359
+
360
+ return { isTradingDay, status, load, ready, refresh, refreshIfStale, pendingRefresh, dispose }
361
+ }