@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 +43 -0
- package/README.md +77 -78
- package/automation/calendar-source.js +361 -0
- package/automation/client.js +351 -58
- package/automation/client.src.cjs +2510 -429
- 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,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.
|
|
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
|
-
>
|
|
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
|
-
|
|
45
|
+
0. **前置**
|
|
52
46
|
|
|
53
|
-
|
|
47
|
+
**建议用独立 profile 启动。** 比如:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh --profile capital --port 8888
|
|
51
|
+
```
|
|
52
|
+
然后访问 `http://localhost:8888`。这样,本插件只属于 capital 这个 profile,避免和其他 profile 冲突。
|
|
54
53
|
|
|
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
|
-
```
|
|
54
|
+
1. **安装插件**
|
|
62
55
|
|
|
63
|
-
|
|
56
|
+
Linux / macOS :
|
|
64
57
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
```
|
|
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
|
-
- **管理自选股:**
|
|
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/
|
|
196
|
-
<img src="assets/
|
|
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.
|
|
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
|
+
}
|