@v587d/capital-generation 2.13.0 → 2.13.1
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 +25 -0
- package/README.md +67 -67
- package/automation/calendar-source.js +361 -0
- package/automation/client.js +341 -58
- package/automation/client.src.cjs +2385 -428
- package/automation/defaults.js +4 -0
- package/automation/index.js +7 -2
- package/automation/package.json +2 -1
- package/automation/runner.js +68 -14
- package/automation/scheduler.js +20 -2
- package/automation/schema.js +16 -0
- package/automation/store.js +37 -12
- package/chart-ui/package.json +1 -1
- package/config/package.json +1 -1
- package/lib/sources/wind-mcp.js +3 -3
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +2 -1
- package/watchlist/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@
|
|
|
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.1] - 2026-10-28
|
|
11
|
+
|
|
12
|
+
投研自动化补上一轮可用性与可观测性:任务页和运行历史重做,交易日历改用深交所官方休市日历;
|
|
13
|
+
进程重启或插件卸载后,已结束的回合不再被误记为未知。另修正 Wind EDB 的取数指引。
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **投研自动化页面重做**:任务页增加最近 30 天运行历史、运行中任务、分类筛选与搜索;模板区、
|
|
18
|
+
任务详情、运行页和通知中心统一版式。首次打开先显示与最终布局同轮廓的骨架屏,不再先闪
|
|
19
|
+
「还没有任务」;模板用「添加」,与「立即运行」分开。
|
|
20
|
+
- **交易日历改为深交所官方休市日历(无需 API Key)**:按月拉取并缓存
|
|
21
|
+
`<dsh home>/storages/capital-automation-calendar/szse.json`;已公布月份覆盖内置表,未公布月份
|
|
22
|
+
回退内置表,覆盖不足 30 天时页面提前告警。交易所公布新一年安排后自动接上,不再需要改代码补表;
|
|
23
|
+
`tradingDaysOnly` 的 fail-closed 语义与 occurrence 计算不变。
|
|
24
|
+
- **Wind EDB 取数改为中文问句优先**:已知指标直接走 `wind_edb_query` 的中文问句,不再要求先
|
|
25
|
+
`wind_edb_search` 拿 code;两个能力都按次消耗上游积分,检索只用于不确定指标名或需要候选清单时。
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **重启或 runtime 卸载把已完成回合误记为未知**:`turn/start` / `turn/end` 现在把回合边界写入
|
|
30
|
+
`turnState`;恢复时按已结束回合的原始终态收口,只有没有回合边界的运行才记
|
|
31
|
+
`unknown_after_restart`,并继续保持不自动重试。
|
|
32
|
+
- **日历刷新在途时提前烧掉当天 occurrence**:启动先等一次日历就绪,刷新未完成时顺延到下一 tick
|
|
33
|
+
再判定,不把慢请求误判为「日历不可用」并跳过当天任务。
|
|
34
|
+
|
|
10
35
|
## [2.13.0] - 2026-10-27
|
|
11
36
|
|
|
12
37
|
新增 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.
|
|
21
|
+
<a href="https://github.com/v587d/capital-generation/releases"><img src="https://img.shields.io/badge/version-2.13.1-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,50 +34,53 @@ 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
|
|
42
|
+
> 适配 DSH`@0.2.1-rc.3`。已在 Linux 的 Web profile 和 Windows 桌面端验证。
|
|
41
43
|
|
|
42
|
-
> [!TIP]
|
|
43
|
-
> **建议用独立 profile 启动。** 安装到 `capital` 后,用下面的命令打开一个全新的 profile:
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
> dsh --profile capital --port 8888
|
|
47
|
-
> ```
|
|
48
|
-
>
|
|
49
|
-
> 然后访问 `http://localhost:8888`。本插件、密钥和默认模式都只属于这个 profile,不会和 DSH web profile 的默认设置冲突。
|
|
45
|
+
0. **建议前置**
|
|
50
46
|
|
|
51
|
-
|
|
47
|
+
> [!TIP]
|
|
48
|
+
> **建议用独立 profile 启动。** 比如:
|
|
49
|
+
>
|
|
50
|
+
> ```bash
|
|
51
|
+
> dsh --profile capital --port 8888
|
|
52
|
+
> ```
|
|
53
|
+
>
|
|
54
|
+
> 然后访问 `http://localhost:8888`。这样,本插件只属于 capital 这个 profile,避免和其他 profile 冲突。
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
1. **安装插件**
|
|
54
57
|
|
|
55
|
-
|
|
56
|
-
dsh plugin --profile capital add @v587d/capital-generation
|
|
57
|
-
```
|
|
58
|
-
或
|
|
59
|
-
```bash
|
|
60
|
-
dsh plugin --profile capital add github:v587d/capital-generation
|
|
61
|
-
```
|
|
58
|
+
Linux / macOS :
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
```bash
|
|
61
|
+
dsh plugin --profile capital add @v587d/capital-generation
|
|
62
|
+
```
|
|
63
|
+
或
|
|
64
|
+
```bash
|
|
65
|
+
dsh plugin --profile capital add github:v587d/capital-generation
|
|
66
|
+
```
|
|
64
67
|
|
|
65
|
-
DSH 桌面端在 「添加插件」
|
|
68
|
+
DSH 桌面端在 「添加插件」 中输入:
|
|
66
69
|
|
|
67
|
-
```input
|
|
68
|
-
https://github.com/v587d/capital-generation
|
|
69
|
-
```
|
|
70
|
-
或
|
|
71
|
-
```input
|
|
72
|
-
@v587d/capital-generation
|
|
73
|
-
```
|
|
74
|
-
|
|
70
|
+
```input
|
|
71
|
+
https://github.com/v587d/capital-generation
|
|
72
|
+
```
|
|
73
|
+
或
|
|
74
|
+
```input
|
|
75
|
+
@v587d/capital-generation
|
|
76
|
+
```
|
|
77
|
+
**本仓库已包含构建产物,普通用户不需要克隆仓库或运行构建命令。**
|
|
75
78
|
|
|
76
79
|
|
|
77
80
|
> [!NOTE]
|
|
78
81
|
> 本插件所有自带密钥均可免费申请、免费日常使用。 哪怕所有密钥留空也不影响插件核心功能,即缺哪个密钥,仅表示对应能力就不可用,其他功能照常。
|
|
79
82
|
|
|
80
|
-
2.
|
|
83
|
+
2. **选填密钥** (打开 DSH 「插件」 → **Capital Generation** →「数据源」)
|
|
81
84
|
|
|
82
85
|
| 密钥 | 用途 |
|
|
83
86
|
| --- | --- |
|
|
@@ -87,44 +90,22 @@ https://github.com/v587d/capital-generation
|
|
|
87
90
|
| [PaddleOCR AIStudio](https://aistudio.baidu.com/paddleocr) | 解析 PDF 和图片中的文字 |
|
|
88
91
|
| [FTShare(非凸科技)](https://market.ft.tech/gateway/doc/p/pb8eizu3) | A 股行情与日 K 截面、主力资金流、涨跌停、龙虎榜席位、股东人数、指数权重、可转债行情、宏观利率,以及期货合约与贵金属行情等第二家上游口径 |
|
|
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
|
+
第三方服务的密钥,需分别向服务方申请;额度和条款以各服务方为准。保存后 **新建会话** 即可生效,无需重启。也可以直接把密钥写入
|
|
94
|
+
`~/.dsh/.credentials.yaml`,名称依次为 `FUYAO_API_KEY`、`ANYSEARCH_API_KEY`、`WIND_API_KEY`、`PADDLE_OCR_TOKEN`、`FTSHARE_API_KEY`。密钥由 DSH 凭据服务保存。
|
|
100
95
|
|
|
101
96
|
3. **新建会话。** 新会话的模式选择器里会看到三个自定义模式。日常研究选 **自动模式**,然后直接描述研究问题;也可以到「设置」→「Agent 预设」把它设为默认模式。
|
|
102
97
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
98
|
+
| 模式 | 适合什么 | 会做什么 | 边界 |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| **自动模式**(推荐) | 投研报告、事件影响、持仓复盘,以及需要同时查数、查资料和出图的综合问题 | 统一调度取数、数据质检、资料检索与绘图,汇总证据后直接答复 | 自动模式不直接取数,也不直接画图;每个数字都要有来源与截止时间 |
|
|
101
|
+
| **行情专家** | 只想拿一份结构化行情、财务或宏观数据 | 查询并保存到当前会话,返回数据引用、来源与截止时间 | 只负责取数,不读取数据内容、不做判断;数据只在当前会话可用 |
|
|
102
|
+
| **资讯专员** | 只要公告、新闻或互动平台材料 | 检索公告与新闻,逐条标注来源与核验状态 | 不做投资分析,也不给买卖建议 |
|
|
108
103
|
|
|
109
|
-
|
|
104
|
+
**分析师**与**绘图专员**是自动模式的内部角色,不出现在选择器里。自动模式会在需要时创建并调度它们;它们没有独立的取数入口,单独打开也读不到本次研究的数据。
|
|
110
105
|
|
|
111
|
-
|
|
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
|
-
| **资讯专员** | 只要公告、新闻或互动平台材料 | 检索公告与新闻,逐条标注来源与核验状态 | 不做投资分析,也不给买卖建议 |
|
|
106
|
+
**每份结构化数据都会先做质检。** 无论你有没有提图表,自动模式收到行情或财务数据后都会先交给分析师检查结构、时间轴、缺失与基础统计;要不要图是检查之后的一个判断。明确说“不要图”时,质检照做,只是停在出图之前。
|
|
122
107
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
**每份结构化数据都会先做质检。** 无论你有没有提图表,自动模式收到行情或财务数据后都会先交给分析师检查结构、时间轴、缺失与基础统计;要不要图是检查之后的一个判断。明确说“不要图”时,质检照做,只是停在出图之前。
|
|
126
|
-
|
|
127
|
-
自动模式会把子角色工作期间的中间回执留在同一轮里处理,等材料收齐后再给出完整结论,而不是每条中间进展都单独回一段。
|
|
108
|
+
自动模式会把子角色工作期间的中间回执留在同一轮里处理,等材料收齐后再给出完整结论,而不是每条中间进展都单独回一段。
|
|
128
109
|
|
|
129
110
|
## 试试这么用
|
|
130
111
|
|
|
@@ -132,9 +113,12 @@ https://github.com/v587d/capital-generation
|
|
|
132
113
|
|
|
133
114
|
[样例:指南针、同花顺复盘分析报告(20261023)](docs/sample/指南针-同花顺-复盘分析报告.md)
|
|
134
115
|
|
|
116
|
+
<details>
|
|
117
|
+
<summary>展开示例指令</summary>
|
|
118
|
+
|
|
135
119
|
**完整投资研究报告**
|
|
136
120
|
|
|
137
|
-
```
|
|
121
|
+
```instruction
|
|
138
122
|
请对[公司名称、俗称]([证券代码])做一份完整投资研究报告。
|
|
139
123
|
以你实际取得的最新可用数据为截止,覆盖业务、财务、估值、价格走势、近期重要事件以及风险与反方观点。
|
|
140
124
|
先给核心判断和最大风险;关键数字注明报告期或交易日、来源和采集时间。
|
|
@@ -143,7 +127,7 @@ https://github.com/v587d/capital-generation
|
|
|
143
127
|
|
|
144
128
|
**事件与公告影响分析**
|
|
145
129
|
|
|
146
|
-
```
|
|
130
|
+
```instruction
|
|
147
131
|
请核查[公司名称]([完整证券代码])的[公告标题及披露日期,或“最近一次业绩公告”],
|
|
148
132
|
分析它对经营、现金流和投资风险可能产生的影响。
|
|
149
133
|
先核对公告原文、披露时间和事项进展,区分已实施事实与尚待批准的方案;
|
|
@@ -153,7 +137,7 @@ https://github.com/v587d/capital-generation
|
|
|
153
137
|
|
|
154
138
|
**持仓与组合复盘**
|
|
155
139
|
|
|
156
|
-
```
|
|
140
|
+
```instruction
|
|
157
141
|
请复盘我的持仓[其他资产如有,请补充]。
|
|
158
142
|
请先核对这些比例是否覆盖全部资产,再分析单只标的、行业与市场的集中风险,
|
|
159
143
|
以及哪些风险会同时影响多个持仓。
|
|
@@ -162,6 +146,8 @@ https://github.com/v587d/capital-generation
|
|
|
162
146
|
给出最需要核实的信息和可观察的防守选项。
|
|
163
147
|
```
|
|
164
148
|
|
|
149
|
+
</details>
|
|
150
|
+
|
|
165
151
|
## 还能做什么
|
|
166
152
|
|
|
167
153
|
- **查数据和资料:** 覆盖 A 股、港股、美股与期货等品种,由专用角色按需获取行情、财务、指数、基金、宏观行业与期货数据,检索公告、新闻和研报。共计 **128** 个数据收集能力和 **14** 个网页检索能力,部分公开数据源无需额外密钥;具体覆盖范围见 [数据收集能力表](docs/data-collector-capabilities.md) 和 [网页检索能力表](docs/web-retriever-capabilities.md)。
|
|
@@ -180,6 +166,12 @@ https://github.com/v587d/capital-generation
|
|
|
180
166
|
|
|
181
167
|
- **精选 Skills:** 当前收录 8 个开源项目、共 70 个 Skill。默认不开启「智能装载 Skills」,逐项清单和勾选都在;打开后清单收起,按角色开放该角色全部方法,由模型按任务挑 1–2 条,运行期不再看勾选。两种状态都按角色分发:研究/组合方法归自动模式,估值/财务/风险/筛选归分析师,文档深读与核验归资讯专员;无需新建会话,下一轮就生效。
|
|
182
168
|
|
|
169
|
+
<p align="center">
|
|
170
|
+
<a href="assets/selected-skills配置.png">
|
|
171
|
+
<img src="assets/selected-skills配置.png" alt="精选 Skills 配置:按 Skill 特点与适用预设筛选" width="800">
|
|
172
|
+
</a>
|
|
173
|
+
</p>
|
|
174
|
+
|
|
183
175
|
| 项目名称 | 收录边界 |
|
|
184
176
|
| :---: | :---: |
|
|
185
177
|
| [The Investment Skills Lexicon](https://github.com/finterm-ai/investment-skills) | Buffett Investment Framework |
|
|
@@ -191,9 +183,17 @@ https://github.com/v587d/capital-generation
|
|
|
191
183
|
| [FinanceSkills](https://github.com/GAJETOso/financeskills) | 8 个估值与财务方法 Skill:折现率(WACC)估算、三表勾稽建模、企业估值与股权价值桥、偿债能力与债务容量、预期信用损失三阶段、银行资本与流动性、保险准备金与综合成本率、账务异常排查;按 IFRS / 美股与国际准则口径写,境内监管阈值与科目名需自行对照 |
|
|
192
184
|
| [Quant Agent Skills](https://github.com/Howard-Jerry/quant-agent-skills) | 1 个跨市场选线 Skill:同一家公司在 A 股与港股、或美股 ADR 与港股各有一条线时该走哪一条——价差、币种与股息税、流动性与通道、披露文件路径各不相同。公司本身只研究一次,便宜的那条线不构成"这家公司更值得买";A/H 溢价与 ADR 平价没有现成条目,须按两条线同一天的收盘价与当日汇率现算,缺任一项就只报缺的那一项 |
|
|
193
185
|
|
|
186
|
+
<a id="capgen-automation" name="capgen-automation"></a>
|
|
187
|
+
- **CapGen 投研自动化:** 内置早间资讯、盘后公告与每日复盘三个模板,也可新建自己的任务;到点自动新建 session 执行,也能手动运行。任务、运行历史与失败通知在同一页,支持运行预算、通知和仅交易日触发。
|
|
188
|
+
|
|
194
189
|
<p align="center">
|
|
195
|
-
<a href="assets/
|
|
196
|
-
<img src="assets/
|
|
190
|
+
<a href="assets/capgen-automation-tasks.png">
|
|
191
|
+
<img src="assets/capgen-automation-tasks.png" alt="投研自动化:任务、日程与运行概览" width="800">
|
|
192
|
+
</a>
|
|
193
|
+
</p>
|
|
194
|
+
<p align="center">
|
|
195
|
+
<a href="assets/capgen-automation-runs.png">
|
|
196
|
+
<img src="assets/capgen-automation-runs.png" alt="投研自动化:运行历史与错误详情" width="800">
|
|
197
197
|
</a>
|
|
198
198
|
</p>
|
|
199
199
|
|
|
@@ -202,7 +202,7 @@ https://github.com/v587d/capital-generation
|
|
|
202
202
|
- **安装后找不到模式?** 确认插件出现在「插件」→ 已安装列表;Web / CLI profile 安装后要重启该 profile,并在新会话选择模式。选择器里应看到自动模式、行情专家、资讯专员;分析师和绘图专员是自动模式的内部角色,不会出现在这里。
|
|
203
203
|
- **配置在哪?** 打开「插件」→ 已安装的 **Capital Generation**,页面顶部就是「数据源 / 精选 Skills / 包含的组件」三格,五把密钥都在第一格;不在 DSH 的普通「设置」页面,也不用点进任何一行组件。保存密钥后新建会话再试。
|
|
204
204
|
- **升级到 2.9.0 后,分析师和绘图专员去哪了?** 这一版把它们收进自动模式内部,不再出现在选择器与预设管理里,避免误开一个没有取数入口的空会话。旧会话仍能按原身份恢复;新任务直接用自动模式提出即可。
|
|
205
|
-
- **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.13.
|
|
205
|
+
- **桌面端升级后仍是旧版?** 桌面端安装时会固定所解析的版本,不会自动升级。卸载后重新安装,再到插件详情查看版本号;如需指定版本,可在地址末尾加 `#v2.13.1` 或 `@2.13.1`。
|
|
206
206
|
- **刚发布的新版本装不到?** 桌面端取包时会跳过**刚发布不久**的版本(宿主侧包管理器的发布冷却策略,实测会静默装成上一个版本),国内镜像的同步也可能滞后一段时间。急着用最新代码,改用仓库地址安装最直接。
|
|
207
207
|
- **网页抓取失败?** 配置卡片中的「允许启动本地提取网页内容」默认开启:AnySearch 提取失败时可尝试本机直连。关闭后,本机直连的具名来源查询也不可用;请检查该开关和网络连接。
|
|
208
208
|
- **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
|
+
}
|