dsh-bayes-predict 0.0.0-stage → 1.1.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.
- package/LICENSE +21 -0
- package/README.md +272 -2
- package/cordis.patch.yml +8 -0
- package/lib/backtest-data.js +311 -0
- package/lib/backtest.js +864 -0
- package/lib/bayes.js +1461 -0
- package/lib/client.js +1635 -0
- package/lib/csi300-components.json +1202 -0
- package/lib/index.js +632 -0
- package/lib/kalman.js +345 -0
- package/lib/market-prior.json +103 -0
- package/lib/market.js +385 -0
- package/lib/pinyin.js +120 -0
- package/lib/realtime.js +161 -0
- package/lib/watchlist.js +355 -0
- package/package.json +60 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,273 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-bayes-predict
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/pg527322814/dsh-bayes-predict/actions/workflows/ci.yml)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
基于 **贝叶斯定理** 的 DeepSeek Harness 个股预测插件(dual-face:Host 工具 + 浏览器面板)。
|
|
7
|
+
|
|
8
|
+
> ⚠️ **平台依赖**:本插件是 DeepSeek Harness(dsh)平台的扩展插件,依赖其宿主环境(`dsh web`、cordis 插件系统、`window.__ModuleLoader__` 等)。该平台目前未随本仓库公开,**单独克隆本仓库无法直接运行**,代码仅供学习、参考与移植。
|
|
9
|
+
|
|
10
|
+
## 功能
|
|
11
|
+
|
|
12
|
+
| 模块 | 说明 |
|
|
13
|
+
|---|---|
|
|
14
|
+
| **多指标信号融合评分** | 假设 H =「未来 N 日上涨」。先验取加权历史胜率(shrunk);MACD 金叉、RSI、均线排列、布林带位置、量能、动量、近 5 日走势等信号作为证据,**双向经验似然比**(看多信号用 LR_bull、看空信号用 LR_bear,时间衰减加权 + 样本收缩),log-odds 更新 → 后验上涨概率(含 95% 置信区间)+ 0-100 评分与评级 |
|
|
15
|
+
| **卡尔曼增益融合** | 证据的收缩强度不再手调:用卡尔曼增益 `K = P⁻/(P⁻+R)` 融合先验与证据,观测方差由 LR 四格表经 Katz 近似给出、先验方差由先验有效样本量给出,**逐股逐日自适应**(等价温度 `T_eff = 1/K`) |
|
|
16
|
+
| **卡尔曼趋势滤波** | 对数价格的局部线性趋势(LLT)状态空间滤波,提供斜率 t 值与标准化新息两个信号源(`kalman_slope` / `kalman_innov`,**默认关闭**,见「卡尔曼增益」章) |
|
|
17
|
+
| **多窗口对比** | 同时输出 2/5/10/20 日后验对比表 |
|
|
18
|
+
| **趋势状态识别** | 贝叶斯 t 检验风格的三状态分类:P(上涨)=P(μ>+δ)、P(下跌)=P(μ<-δ)、P(震荡),阈值 δ = max(0.5·SE, 年化 5% 经济当量),防大样本下微小趋势误判 |
|
|
19
|
+
| **持仓风险估计** | EWMA 波动率(RiskMetrics λ=0.94)+ **经验分位数 VaR/CVaR 为主**(正态估计对照)、历史最大回撤 |
|
|
20
|
+
| **信号归因** | 输出贡献绝对值 Top3 的信号;方向标签按**实际贡献符号**显示(名义方向经实证校准后可能与贡献相反) |
|
|
21
|
+
| **策略回测** | 严格防前视的参数化回测:逐换仓日现场评分 → 门槛 + TopN 选股 → 等分资金模拟换仓;支持大盘择时、ST/价格/市值/上市天数筛选、止损与仓位/板块上限、佣金与印花税;输出策略·高分 Top / 策略·低分 Top / 池基准三条曲线 |
|
|
22
|
+
| **全市场选股筛选** | 与回测**完全同一套条件**的单日截面选股(不做换仓模拟),表格可排序、可一键加自选、点行跳转单股分析 |
|
|
23
|
+
| **自选股(实时口径)** | 自选日锚定价 + 「自选日至今」实时涨跌幅(真实价、不复权口径,与券商 App 一致),可与选股/回测共享同一份评分缓存 |
|
|
24
|
+
| **拼音首字母搜索** | 代码 / 中文名 / 拼音首字母三合一模糊匹配(`ZGYH` → 招商银行),GB2312 码段法实现、多音字变体展开、**零依赖** |
|
|
25
|
+
| **walk-forward 回测(模型校准)** | 工具可选 `backtest` 参数:输出 Brier / log-loss / 方向准确率 / 可靠性表 / 分级命中率 / 自动校准温度,并与「无信息基线」「只用先验」等口径对照 |
|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
**方式一:一键安装(推荐,需要能访问 github.com)**
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
dsh plugin --profile web add github:pg527322814/dsh-bayes-predict
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
装完**重启 `dsh web`** 生效(插件层在启动时合成;client bundle 的 HMR 仅在 `pnpm run dev:web` 重建时可用)。
|
|
36
|
+
|
|
37
|
+
**方式二:手动安装(离线,或网络访问 github.com 受限时)**
|
|
38
|
+
|
|
39
|
+
1. 插件包拷贝至 `%USERPROFILE%\.dsh\profiles\web\node_modules\dsh-bayes-predict`
|
|
40
|
+
(该目录不存在时先创建;profile 的 baseUrl 锚定在 `profiles\web`,node 解析会向上逐级查找 node_modules)
|
|
41
|
+
2. `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml` 追加:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
- insert:
|
|
45
|
+
- id: bayes-predict
|
|
46
|
+
name: 'dsh-bayes-predict'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3. 重启 `dsh web`。
|
|
50
|
+
|
|
51
|
+
> ⚠️ 客户端契约:`lib/client.js` 的 factory **必须导出 `inject` 数组**(如 `exports.inject = ["slots"]`)。
|
|
52
|
+
> cordis 依据模块导出的 `inject` 决定注入哪些服务;缺失时 `apply(ctx)` 里访问 `ctx.slots`
|
|
53
|
+
> 会直接抛 `cannot get property "slots" without inject`(`package.json` 的 `dsh.client.inject`
|
|
54
|
+
> 只影响 boot 清单,不替代模块导出)。
|
|
55
|
+
|
|
56
|
+
**回测 / 选股缓存目录(可选但强烈建议)**:统一数据目录由 `DSH_STOCK_CACHE_DIR` 指定,未设置时默认 `%USERPROFILE%\.dsh\stock-bayes\`,结构为:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
<cacheRoot>/
|
|
60
|
+
├── universe.json 多档股票池 { pools: { all, sh300, custom } } + 每只股票标签
|
|
61
|
+
├── data/<code>.json 每只股票K线
|
|
62
|
+
├── csi300.json 指数(沪深300)
|
|
63
|
+
├── scores/h<horizon>.json 预评分(列式紧凑存储 + 原子写)
|
|
64
|
+
└── watchlist.json 自选股清单(原子写)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
首次使用某只/某个基准日时,缺失的行情与评分会**现场拉取并计算**(沪深300 量级约数秒,全市场数千只约分钟级),之后一直命中缓存;面板「🔄 刷新行情缓存」做增量刷新(只拉新 K 线去重追加)。管道中缺少 `universe.json` 时,股票池回退到**内置沪深300 成分**(`lib/csi300-components.json`)。
|
|
68
|
+
|
|
69
|
+
> 说明:本仓库只包含插件运行时。用于构建全市场 `universe.json` 与评分预热的同步脚本属于开发侧工具,未随包发布。
|
|
70
|
+
|
|
71
|
+
## 使用
|
|
72
|
+
|
|
73
|
+
**对话中**(重启后开新会话,让 agent 看到新工具):
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
分析一下 600519
|
|
77
|
+
用贝叶斯方法评估一下 300750 未来 5 天的上涨概率
|
|
78
|
+
带上历史回测看看 600519 的模型校准
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**可视化面板**:侧边栏底部「📊 贝叶斯股票分析」快捷按钮 → 弹出居中的固定浮层,内含**四个页签**。
|
|
82
|
+
|
|
83
|
+
### 单股分析
|
|
84
|
+
|
|
85
|
+
输入代码(600519 / sh600519 / 000001 / AAPL),点「贝叶斯分析」。未发起分析时该页显示**算法说明**空态(模型假设、融合公式与各信号方向校准说明)。报告含:综合判断(先验 → 后验 + 95% 区间 + 评分/评级)、多窗口对比、趋势三状态、EWMA / 经验 VaR 风险、信号证据明细与归因、近 60 日走势、技术面快照。
|
|
86
|
+
|
|
87
|
+
口径透明:同时给出「证据原始 log-odds 和」与「实际作用于先验的增量」(收缩前的量不等于实得变化);当信号仅使概率移动不足 1 个百分点时,明确提示**结论主要由先验主导**,而非分析失效。
|
|
88
|
+
|
|
89
|
+
### 策略回测
|
|
90
|
+
|
|
91
|
+
可配置股票资源池、评分窗口(2/5/10/20 日)、每期持有交易日、同时持有个数、评分门槛区间、初始资金、回测区间、大盘择时规则(R1~R4)、筛选条件(排除 ST / 价格上下限 / 市值下限 / 排除次新股)、风控(个股止损 / 单只最大仓位 / 同板块最多持有数)与交易费用(佣金 + 印花税)。
|
|
92
|
+
|
|
93
|
+
- **严格防前视**:每个换仓日只用当日及之前的数据估计似然比与先验
|
|
94
|
+
- **市值下限按"截至换仓日的历史推算市值"过滤**,消除前视偏差(`historicalCap=0` 可退回快照市值)
|
|
95
|
+
- **三条对照曲线**:策略·高分 Top / 策略·低分 Top(反向对照)/ 池基准(当期全部合格股票等权),三者差距即可检验「评分排序是否携带信息」
|
|
96
|
+
- **评分缓存**按「模型版本 × 模型参数指纹 × 逐股数据指纹」三维校验,参数变化时旧行全部作废、全新写入;数据追加时只补算缺失日期;跨池跨 step 的评分按 (code, date) 累积复用
|
|
97
|
+
- **多 horizon 预热**:一次运行同时评分并缓存 2/5/10/20 日(共享指标与信号计算,约 2~2.5 倍单窗口成本),之后切换窗口零等待
|
|
98
|
+
|
|
99
|
+
### 选股筛选
|
|
100
|
+
|
|
101
|
+
用与回测完全相同的一套条件,但**不做换仓模拟**——只对基准日做一次截面评分并列表(基准日留空 = 行情最新交易日;非交易日自动回退并标记)。表头可点击排序;行首 ★/☆ 一键加入或移出自选(不影响整行跳转);点任意行跳到「单股分析」并自动分析。评分与回测**共享同一份缓存文件**。
|
|
102
|
+
|
|
103
|
+
### 自选股
|
|
104
|
+
|
|
105
|
+
核心是**「自选日至今涨跌幅」**,默认拉实时行情:
|
|
106
|
+
|
|
107
|
+
- **实时价 + 真实价口径(本页刻意与其余页签不同)**:自选价 = 自选日的**不复权**收盘,最新价 = **腾讯实时现价**。实时接口只能拿到真实价,若把后复权锚点(茅台 ≈9200)与真实现价(≈1300)混算会得出 −84% 这类荒唐跌幅;且腾讯 hfq 日线盘中常不含当日 bar、新交易日可能跨复权基准断裂。故本页单独用真实价口径,与券商 App 显示一致
|
|
108
|
+
- **降级路径清晰**:拿不到报价的股票仍按不复权口径但标记 `live=false` 并显示 `⚠︎`(tooltip 给原因);`live=0` 可整体回退到本地缓存(后复权)收盘价,离线可用
|
|
109
|
+
- **锚点不漂移**:加入时把「自选日 + 自选价」写进 `watchlist.json`,此后涨跌幅永远以它为起点;重复收藏只刷新名称,**不重置锚点**
|
|
110
|
+
- 汇总行给出平均涨跌幅、盈亏只数、`实时 N/M 只 @MM-DD HH:mm`;表头筛选行支持 代码/拼音/名称 模糊搜索、自选日起止、盈亏状态、评分下限;「🔄 刷新」带 `force=1` 绕过 TTL
|
|
111
|
+
|
|
112
|
+
### Host 接口
|
|
113
|
+
|
|
114
|
+
| 方法 | 路径 | 说明 |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| GET | `/bayes-predict/analyze?symbol=&horizon=` | 单股分析报告 |
|
|
117
|
+
| POST | `/bayes-predict/backtest?pool=&horizon=&step=&topN=&threshold=&...` | 策略回测(含低分 Top 对照与多窗口预热) |
|
|
118
|
+
| GET | `/bayes-predict/screen?pool=&horizon=&date=&limit=&...` | 单日截面选股 |
|
|
119
|
+
| GET | `/bayes-predict/watchlist?horizon=&date=&live=&force=` | 自选股报告;`codesOnly=1` 只取代码清单(轻量) |
|
|
120
|
+
| POST | `/bayes-predict/watchlist/add\|remove\|clear?symbol=` | 自选变更,**一律 POST**(GET 返回 405,防止预取/缓存把 GET 当副作用请求) |
|
|
121
|
+
| GET | `/bayes-predict/pools` | 资源池与标签(供面板动态渲染) |
|
|
122
|
+
| POST | `/bayes-predict/refresh?pool=` | 增量刷新行情缓存 |
|
|
123
|
+
|
|
124
|
+
## 代码结构
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
lib/
|
|
128
|
+
├── index.js Host 端:bayes_stock_predict 工具 + 9 条 /bayes-predict/* 路由 + markdown 报告
|
|
129
|
+
├── bayes.js 贝叶斯核心:指标 / 双向信号 / 时间衰减四格表 LR / 卡尔曼增益融合(默认)/ 趋势 / EWMA-经验风险 / 多窗口 / walk-forward 回测
|
|
130
|
+
├── kalman.js 卡尔曼滤波:LLT 状态空间滤波 + 卡尔曼增益证据融合 + Katz 对数方差近似
|
|
131
|
+
├── backtest.js 参数化回测引擎(严格防前视)+ screenStocks() 单日截面选股(同一套条件与缓存)
|
|
132
|
+
├── backtest-data.js 回测数据提供层:缓存根、资源池、列式编解码、沪深300 成分、增量刷新
|
|
133
|
+
├── watchlist.js 自选股:watchlist.json 持久化 + 自选日/自选价锚点 + 实时收益 + 复用 screenStocks 评分
|
|
134
|
+
├── realtime.js 自选实时行情:批量报价(60 只/请求)+ 不复权日线 + liveAnchorStats 纯函数 + 短 TTL 缓存
|
|
135
|
+
├── pinyin.js 汉字 → 拼音首字母(GB2312 码段法,零依赖)+ 多音字变体展开
|
|
136
|
+
├── market.js 行情获取:代码归一化、腾讯 K 线(含复权断层检测)、腾讯/新浪实时、批量报价、指数日K、TTL 缓存
|
|
137
|
+
├── market-prior.json 全市场横截面先验(通用 LR 表,启动时自动加载)
|
|
138
|
+
├── csi300-components.json 沪深300 成分快照(无 universe.json 时的回退股票池)
|
|
139
|
+
└── client.js 浏览器端:React 面板(侧边栏入口 → 四个页签),SVG 自绘走势图/仪表/权益曲线
|
|
140
|
+
|
|
141
|
+
test/
|
|
142
|
+
├── kalman.test.mjs 卡尔曼数值单测(57 项:零噪声极限、无前视不变式、增益等价 T_eff=1/K、方差单调性)
|
|
143
|
+
├── smoke.mjs 插件冒烟(36 项:默认路径 / 回退路径 / 回测字段 / 报告结构)
|
|
144
|
+
├── pinyin.test.mjs 拼音首字母与多音字变体(38 项)
|
|
145
|
+
├── screen.test.mjs 选股筛选(129 项:条件与排序 / limit 截断 / 非交易日回退 / 缓存复用 / codes 子集,
|
|
146
|
+
│ 以及与 runBacktest 同一基准日候选集逐只一致的等价性)
|
|
147
|
+
├── watchlist.test.mjs 自选股(78 项:归一化 / 锚点语义与停牌回退 / 重复加入不重置锚点 / 持久化往返 / 评分复用)
|
|
148
|
+
├── realtime.test.mjs 实时口径纯函数(36 项)
|
|
149
|
+
├── routes.test.mjs Host 契约(假 ctx 收集注册结果:1 个工具 + 9 条路由的命名)
|
|
150
|
+
├── client-render.test.mjs 客户端 bundle 渲染(138 项:迷你 React + fetch 桩,跑通四页签交互链路与拼音搜索)
|
|
151
|
+
├── client-fixtures.json 上面用到的真实接口响应夹具(已裁剪)
|
|
152
|
+
└── normalize.test.mjs 代码归一化单测
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## 模型要点
|
|
156
|
+
|
|
157
|
+
- 后验更新:`logit(H) += K · Σ strength_i·log(LR_i)`;收缩强度由**卡尔曼增益 K** 决定(默认口径),`fusion: "temperature"` 可回退到固定 T=3 的旧口径
|
|
158
|
+
- **双向 LR**:LR_bull = P(看多信号|涨)/P(看多信号|跌),LR_bear = P(看空信号|涨)/P(看空信号|跌);四格表统计 + 拉普拉斯平滑 + 向 1 的样本收缩(k=20)+ 单信号截断 [1/4, 4]
|
|
159
|
+
- **信号方向实证校准**(审计 → 方向修正 → 消融确认):
|
|
160
|
+
- 方向正确保留:MACD 金叉/死叉、MACD 柱、近 5 日走势、影线形态、跳空、缠论背驰
|
|
161
|
+
- **反转方向**:价格 vs MA20(追高后回归)、量能(放量冲高回落/恐慌见底)
|
|
162
|
+
- **弱信号弱化**:RSI、均线排列、布林带、动量、ATR(强度打折)
|
|
163
|
+
- **时间衰减**:LR 与先验均按指数衰减加权(半衰期 200 交易日),近期样本权重更高
|
|
164
|
+
- **波动率归一化**:动量/近 5 日收益信号阈值按滚动 σ 自适应(|x| > 0.5·σ·√k),跨股票可比
|
|
165
|
+
- **信号强度**:每个信号输出 strength ∈ (0,1],弱信号(如 RSI 50 附近)贡献打折
|
|
166
|
+
- **分层先验(默认启用)**:启动时加载 `market-prior.json` 通用 LR 表,单股 LR 与之混合(w = n/(n+80))
|
|
167
|
+
- 先验:近 `sampleWindow` 日加权胜率 shrunk 到 0.5(强度 30),`horizon` 默认 5 日
|
|
168
|
+
- 风险:EWMA σ(λ=0.94)为主,VaR/CVaR 以经验分位数为主输出、正态近似对照
|
|
169
|
+
- `MODEL_VERSION`:模型定义(信号集合 / LR 估计 / 融合口径 / 先验参数)变更时必须递增——评分缓存据此失效重建,避免新旧模型结果混用。当前为 **2**
|
|
170
|
+
|
|
171
|
+
## 卡尔曼增益
|
|
172
|
+
|
|
173
|
+
### 融合层:卡尔曼增益替代手调 temperature(默认启用)
|
|
174
|
+
|
|
175
|
+
把潜在 log-odds 视为待估状态:先验给出 `z ~ N(logit(prior), P⁻)`,证据给出一次含噪观测
|
|
176
|
+
`y = Σ strength_k·log(LR_k)`,观测方差 `R = Σ strength_k²·Var(log LR_k)·γ`。于是
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
K = P⁻ / (P⁻ + R) ← 卡尔曼增益
|
|
180
|
+
z = logit(prior) + K·y
|
|
181
|
+
P = (1 − K)·P⁻ ← 后验方差(置信区间由它给出,不再是启发式 nEff)
|
|
182
|
+
等价收缩 T_eff = 1/K = 1 + R/P⁻
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
- `Var(log LR_k)` 由四格表计数经 **Katz 对数风险比近似**给出,并乘上 shrink / 分层先验混合的雅可比修正:**样本少的信号自动获得大方差、增益自动变小**,不需要为「这个信号可不可信」再手调参数
|
|
186
|
+
- `γ`(`kalmanObsInflate`,默认 **2**)是证据相关性/过拟合的膨胀因子:技术信号彼此相关,独立假设会低估 R、高估证据精度
|
|
187
|
+
- `P⁻` 由先验有效样本量经 delta 方法给出:`Var(logit(p)) ≈ 1/(n·p(1−p))`
|
|
188
|
+
- 后验区间来自 `P`(logit 正态近似),取代了原先没有概率意义的 `nEff` 启发式
|
|
189
|
+
|
|
190
|
+
### 信号层:LLT 状态空间滤波(默认关闭)
|
|
191
|
+
|
|
192
|
+
对对数价格建局部线性趋势模型(`level` 随机游走 + `slope` 慢变,观测噪声极小),一次 O(n) 前向递推给出滤波斜率 `slope/√P_ss`(t 值归一化)与标准化新息 `v/√S`,对应信号 `kalman_slope` / `kalman_innov`。
|
|
193
|
+
|
|
194
|
+
**硬约束(均写进单测)**:
|
|
195
|
+
|
|
196
|
+
- **只做前向滤波,绝不用 RTS 平滑**——平滑器会用到未来观测,把前视泄漏进 LR 四格表
|
|
197
|
+
- **滤波参数必须因果**:观测噪声用滚动窗口估计;`test/kalman.test.mjs` 用「续接未来 K 线不改变历史滤波状态」的不变式守住这一点
|
|
198
|
+
- **必须预计算**:`signalDirectionsAt` 被 LR 估计/审计/回测按每根 K 线各调一次,递推放进信号函数会退化成 O(n²)
|
|
199
|
+
|
|
200
|
+
### 实证结论(为何是这样默认)
|
|
201
|
+
|
|
202
|
+
**A) 卡尔曼融合 vs 手调 T=3(无前视对比,Brier 越低越好)**
|
|
203
|
+
|
|
204
|
+
| 口径 | 400 只 A 股 / 2.4 年(测试集) | 52 只沪深300 / 10 年(测试集) |
|
|
205
|
+
|---|---|---|
|
|
206
|
+
| priorOnly(只用先验、忽略证据) | 0.2547 | 0.2540 |
|
|
207
|
+
| **卡尔曼融合 γ=2(默认)** | 0.2552 | 0.2542 |
|
|
208
|
+
| 卡尔曼融合 γ=6 | 0.2548 | 0.2540 |
|
|
209
|
+
| 手调 temperature=3(旧默认) | 0.2571 | 0.2551 |
|
|
210
|
+
| T=1(原始证据强度) | 0.2727 | 0.2638 |
|
|
211
|
+
|
|
212
|
+
配对差异(每只股票一个观测,负值 = 卡尔曼更好):γ=2 时 **−0.0020 ±0.0001(89.0% 股票改善)** 与 **−0.0009 ±0.0001(90.9%)**,两个独立样本同向且都高度显著;γ∈[2,6] 内收益已趋平。
|
|
213
|
+
|
|
214
|
+
**B) 卡尔曼信号的消融(默认关闭的依据)**
|
|
215
|
+
|
|
216
|
+
| 检验 | ΔBrier | 改善率 |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| 加 `kalman_slope` / `kalman_innov`(T=1 口径) | **+0.00026 ±0.00005** | 15.4% |
|
|
219
|
+
| 先移除 `trend_recent` / `ma_align` 再加(检验"是否只是冗余") | **+0.00021 ±0.00006** | 19.2% |
|
|
220
|
+
|
|
221
|
+
两者都为正 ⇒ 不是与趋势类信号冗余,而是**样本外没有边际价值**。其原始信息量并不弱(十年池化审计:`kalman_slope` 分段 spread +6.33%/+14.40%/+8.24%,t 最高 5.87),但**全量审计 ≠ 样本外有效**——这正是 ROC(20) / streak / beta 被移除的同一条教训。
|
|
222
|
+
|
|
223
|
+
**C) 必须一起看的负面结论**
|
|
224
|
+
|
|
225
|
+
在合计 34.7 万个预测点上,本模型的 Brier **始终高于 priorOnly**(最好配置也只到 −0.0001)。γ 越大越好、最终收敛到 priorOnly,本质是模型在自动学会「少信证据」。因此卡尔曼增益的价值应表述为:**在不引入前视的前提下,把手调的固定收缩换成自适应收缩,稳住了过度自信**(相对 T=1 改善 −0.0167,相对 T=3 改善 −0.0020 / −0.0009),**而不是"带来了新的预测力"**。
|
|
226
|
+
|
|
227
|
+
### 开关与调参
|
|
228
|
+
|
|
229
|
+
| 参数 | 默认 | 说明 |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| `fusion` | `"kalman"` | 设 `"temperature"` 回退旧口径(T=3) |
|
|
232
|
+
| `kalmanObsInflate` | `2` | 证据方差膨胀 γ;调大 → 更保守、更贴先验(γ→∞ 即退化为 priorOnly) |
|
|
233
|
+
| `kalmanSignals` | `false` | 是否启用 `kalman_slope` / `kalman_innov` 两个新信号 |
|
|
234
|
+
| `kalmanSlopeT` / `kalmanInnovZ` | `1.2` / `1.5` | 两个信号的触发阈值(t 值 / 标准化新息) |
|
|
235
|
+
| `kalmanSlopeDir` / `kalmanInnovDir` | `-1` / `-1` | 方向(−1 反转)。**不影响结论**:LR 是双向的,会自动学到反向关系 |
|
|
236
|
+
|
|
237
|
+
报告与面板中的 `calibration.fusion / kalmanGain / effectiveTemperature / priorVar / obsVar / stateVar` 与顶层 `kalman` 快照(斜率 t、标准化新息 z、年化斜率)即为上述量的诊断出口。
|
|
238
|
+
|
|
239
|
+
## 数据源
|
|
240
|
+
|
|
241
|
+
免费、无需 key:
|
|
242
|
+
|
|
243
|
+
- **A 股日 K**:腾讯 fqkline 后复权 hfq(最多约 700 根 ≈2023-12 至今;主/备用域名自动切换规避限流),东财后复权兜底
|
|
244
|
+
- **实时快照**:腾讯(A 股)/ 腾讯 `us` 前缀(美股);批量报价一次最多 60 只
|
|
245
|
+
- **指数**:东财沪深300 日 K(供市场 beta 与池基准)
|
|
246
|
+
- **美股日 K**:新浪全历史
|
|
247
|
+
- 内存 TTL 缓存 30 分钟(实时报价 60s / 不复权日线 5min)
|
|
248
|
+
|
|
249
|
+
> ⚠️ 数据口径:评分、回测与单股分析均使用**后复权(hfq)**——收益序列含分红再投资,除权日无假跳空,信号干净;但价格非真实股价。**自选股页是唯一例外**,它用真实价(不复权)口径,理由见上文。
|
|
250
|
+
|
|
251
|
+
> ⚠️ **腾讯 hfq 复权断层防护**:腾讯后复权的复权基准随最新交易日滚动,接口返回的最后一天可能发生"跨基准断裂"(假跳变)。`fetchKlines` 已内置断层检测:hfq 最后一天 |单日涨跌| > 8% 时拉不复权序列核对,判定复权断层则丢弃最后一天(宁缺毋滥);`refreshCache` 检测到缓存末尾断层时自动全量替换修复。
|
|
252
|
+
|
|
253
|
+
## 开发
|
|
254
|
+
|
|
255
|
+
零第三方依赖,Node 18+。
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
npm test
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
依次运行 9 个测试文件:`kalman`(57) → `smoke`(36) → `pinyin`(38) → `screen`(129) → `watchlist`(78) → `realtime`(36) → `routes`(9 条路由契约) → `client-render`(138,迷你 React + fetch 桩跑通四页签交互) → `normalize`。CI(Node 20 / 22)在每次 push 与 PR 上执行同一套测试。
|
|
262
|
+
|
|
263
|
+
## 卸载
|
|
264
|
+
|
|
265
|
+
- 删除 `cordis.patch.yml` 中 `bayes-predict` 的 insert 行
|
|
266
|
+
- 删除 `node_modules\dsh-bayes-predict`
|
|
267
|
+
- 重启 `dsh web`
|
|
268
|
+
|
|
269
|
+
## 许可证
|
|
270
|
+
|
|
271
|
+
[MIT](./LICENSE) © pg
|
|
272
|
+
|
|
273
|
+
> ⚠️ 本插件输出由贝叶斯统计模型基于历史行情生成,仅供参考,不构成投资建议。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-bayes-predict bundle patch.
|
|
2
|
+
# A bundle patch is applied as its own layer over the profile root; the row
|
|
3
|
+
# id is addressed for later overrides (last write per row wins). Installing
|
|
4
|
+
# the package via `dsh plugin --profile web add dsh-bayes-predict`
|
|
5
|
+
# auto-activates this layer — no manual cordis.patch.yml edit needed.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: bayes-predict
|
|
8
|
+
name: 'dsh-bayes-predict'
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// dsh-bayes-predict / lib/backtest-data.js
|
|
2
|
+
// 统一缓存数据提供层:缓存根目录 = env DSH_STOCK_CACHE_DIR 或 %USERPROFILE%\.dsh\stock-bayes。
|
|
3
|
+
// 结构:
|
|
4
|
+
// universe.json 多档股票池 { pools: { all, sh300, custom } }
|
|
5
|
+
// data/<code>.json 每只股票东财后复权全历史K线
|
|
6
|
+
// csi300.json 沪深300指数日K(择时用)
|
|
7
|
+
// scores/h<horizon>.json 预评分缓存 { horizon, rebalanceDates, rows: {code:{date:{s,r,p,c}}} }
|
|
8
|
+
// meta.json 各文件生成时间
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, renameSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { fetchKlines } from "./market.js";
|
|
14
|
+
|
|
15
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
export function cacheRoot() {
|
|
18
|
+
return process.env.DSH_STOCK_CACHE_DIR || join(os.homedir(), ".dsh", "stock-bayes");
|
|
19
|
+
}
|
|
20
|
+
export function scoresPath(h) { return join(cacheRoot(), "scores", `h${h}.json`); }
|
|
21
|
+
|
|
22
|
+
// ================= 评分缓存列式紧凑编码 =================
|
|
23
|
+
// 行式:rows[code][date] = {s, p}(日期键/属性名/花括号每处重复,大池文件膨胀)
|
|
24
|
+
// 列式:rows[code] = { s: number[], p: number[] },第 i 个元素对应 rebalanceDates[i];
|
|
25
|
+
// s 为 0-100 整数,p 定点化为 0-65535 整数(精度 ~7.6e-6),缺失日期用 -1 哨兵。
|
|
26
|
+
// 实测(2000 只 × 140 日):体积 12.1MB→2.4MB(5.1×),stringify 8×,parse 16×。
|
|
27
|
+
const P_SCALE = 65535;
|
|
28
|
+
|
|
29
|
+
/** 嵌套行 → 列式(写入磁盘前调用)。 */
|
|
30
|
+
export function encodeColumnar(payload) {
|
|
31
|
+
const dates = payload.rebalanceDates;
|
|
32
|
+
const idx = new Map();
|
|
33
|
+
for (let i = 0; i < dates.length; i++) idx.set(dates[i], i);
|
|
34
|
+
const rows = {};
|
|
35
|
+
for (const [code, row] of Object.entries(payload.rows)) {
|
|
36
|
+
const sArr = new Array(dates.length).fill(-1);
|
|
37
|
+
const pArr = new Array(dates.length).fill(-1);
|
|
38
|
+
for (const [d, v] of Object.entries(row)) {
|
|
39
|
+
const i = idx.get(d);
|
|
40
|
+
if (i == null) continue; // 行内日期应 ⊆ rebalanceDates 并集,防御性跳过
|
|
41
|
+
sArr[i] = v.s;
|
|
42
|
+
pArr[i] = Math.round(v.p * P_SCALE);
|
|
43
|
+
}
|
|
44
|
+
rows[code] = { s: sArr, p: pArr };
|
|
45
|
+
}
|
|
46
|
+
return { ...payload, cols: true, rows };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 列式 → 嵌套行(读入后还原给引擎用)。 */
|
|
50
|
+
export function decodeColumnar(raw) {
|
|
51
|
+
const dates = raw.rebalanceDates;
|
|
52
|
+
const rows = {};
|
|
53
|
+
for (const [code, col] of Object.entries(raw.rows)) {
|
|
54
|
+
const row = {};
|
|
55
|
+
const sArr = col.s || [];
|
|
56
|
+
const pArr = col.p || [];
|
|
57
|
+
for (let i = 0; i < dates.length; i++) {
|
|
58
|
+
const s = sArr[i];
|
|
59
|
+
if (s != null && s >= 0) {
|
|
60
|
+
row[dates[i]] = { s, p: pArr[i] != null && pArr[i] >= 0 ? pArr[i] / P_SCALE : 0 };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
rows[code] = row;
|
|
64
|
+
}
|
|
65
|
+
return { ...raw, rows };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 按代码前缀返回带市场前缀的代码:60/68→sh,00/30→sz,4/8/92→bj。 */
|
|
69
|
+
export function prefixed(code) {
|
|
70
|
+
if (/^6/.test(code)) return `sh${code}`;
|
|
71
|
+
if (/^(0|3)/.test(code)) return `sz${code}`;
|
|
72
|
+
return `bj${code}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 多档股票池:读取 universe.json 的 pools;缺失时回退到内置沪深300成分。 */
|
|
76
|
+
export async function loadUniversePools() {
|
|
77
|
+
const p = join(cacheRoot(), "universe.json");
|
|
78
|
+
if (existsSync(p)) {
|
|
79
|
+
try {
|
|
80
|
+
const j = JSON.parse(readFileSync(p, "utf8"));
|
|
81
|
+
if (j && j.pools && j.pools.all && Array.isArray(j.pools.all.codes)) return j.pools;
|
|
82
|
+
} catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
// 回退:内置沪深300成分
|
|
85
|
+
const comps = await loadCsi300Components();
|
|
86
|
+
const sh300 = (comps || []).filter((s) => /^sh60[0135]\d{3}$/.test(s)).map((s) => s.slice(2));
|
|
87
|
+
return { all: { label: "沪深300成分(沪市主板)", codes: sh300 }, sh300: { label: "沪深300成分(沪市主板)", codes: sh300 }, custom: { label: "自选", codes: [] } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 沪深300成分(内置静态文件优先,其次新浪拉取)。 */
|
|
91
|
+
async function loadCsi300Components() {
|
|
92
|
+
const builtin = join(PLUGIN_DIR, "csi300-components.json");
|
|
93
|
+
try {
|
|
94
|
+
if (existsSync(builtin)) {
|
|
95
|
+
const arr = JSON.parse(readFileSync(builtin, "utf8"));
|
|
96
|
+
if (Array.isArray(arr) && arr.length >= 100) return arr.map((c) => String(c.symbol || c.code || c));
|
|
97
|
+
}
|
|
98
|
+
} catch { /* ignore */ }
|
|
99
|
+
try {
|
|
100
|
+
const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36";
|
|
101
|
+
const all = [];
|
|
102
|
+
for (let pg = 1; pg <= 6; pg++) {
|
|
103
|
+
const url = `https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php/Market_Center.getHQNodeData?page=${pg}&num=300&sort=symbol&asc=1&node=hs300&symbol=&_s_r_a=page`;
|
|
104
|
+
const r = await fetch(url, { headers: { "User-Agent": UA, Referer: "https://finance.sina.com.cn/" } });
|
|
105
|
+
const t = await r.text();
|
|
106
|
+
if (!t || !t.startsWith("[")) break;
|
|
107
|
+
const arr = JSON.parse(t);
|
|
108
|
+
if (!arr.length) break;
|
|
109
|
+
for (const x of arr) all.push(String(x.symbol));
|
|
110
|
+
}
|
|
111
|
+
if (all.length >= 100) return all;
|
|
112
|
+
} catch { /* ignore */ }
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 组装 provider。
|
|
118
|
+
* @param {object} opts { pool: 'all'|'sh300'|'custom', customCodes: string[] }
|
|
119
|
+
* @returns {{getKlines, universe, getIndexKlines, getScores, saveScores}}
|
|
120
|
+
*/
|
|
121
|
+
export async function buildProvider(opts = {}) {
|
|
122
|
+
const pools = await loadUniversePools();
|
|
123
|
+
const poolKey = opts.pool || "all";
|
|
124
|
+
let universe;
|
|
125
|
+
if (poolKey === "custom" && Array.isArray(opts.customCodes) && opts.customCodes.length) {
|
|
126
|
+
universe = opts.customCodes;
|
|
127
|
+
} else {
|
|
128
|
+
const pool = pools[poolKey] || pools.all;
|
|
129
|
+
universe = (pool && pool.codes) || [];
|
|
130
|
+
}
|
|
131
|
+
if (!universe.length) throw new Error("股票池为空(pool=" + poolKey + ")");
|
|
132
|
+
|
|
133
|
+
// 标签(ST/板块/市值/名称):读 universe.json 的 tags;
|
|
134
|
+
// snapshotDate = 快照生成日(市值/ST/成分标记的时点),历史市值推算以此为基准价锚点
|
|
135
|
+
let tags = {};
|
|
136
|
+
let snapshotDate = null;
|
|
137
|
+
try {
|
|
138
|
+
const u = JSON.parse(readFileSync(join(cacheRoot(), "universe.json"), "utf8"));
|
|
139
|
+
if (u && u.tags) tags = u.tags;
|
|
140
|
+
if (u && typeof u.generatedAt === "string") snapshotDate = u.generatedAt.slice(0, 10);
|
|
141
|
+
} catch { tags = {}; }
|
|
142
|
+
const getTags = (code) => tags[code] || {};
|
|
143
|
+
|
|
144
|
+
// K线读取:优先缓存目录 data/,缺失则现场拉取并落盘
|
|
145
|
+
const dataDir = join(cacheRoot(), "data");
|
|
146
|
+
const kc = new Map();
|
|
147
|
+
// 数据指纹:data/ 目录内最新文件的 mtime(毫秒)。评分缓存据此判断底层数据是否
|
|
148
|
+
// 被追加/重写(刷新行情、换复权口径),指纹变化 → 评分缓存失效重算。
|
|
149
|
+
// 现场拉取落盘的新文件同样更新指纹,避免"冷拉取后必重算一次"的抖动。
|
|
150
|
+
let dataStamp = null;
|
|
151
|
+
try {
|
|
152
|
+
let max = 0;
|
|
153
|
+
for (const n of readdirSync(dataDir)) {
|
|
154
|
+
if (!n.endsWith(".json")) continue;
|
|
155
|
+
const st = statSync(join(dataDir, n));
|
|
156
|
+
if (st.mtimeMs > max) max = st.mtimeMs;
|
|
157
|
+
}
|
|
158
|
+
if (max > 0) dataStamp = Math.round(max);
|
|
159
|
+
} catch { dataStamp = null; }
|
|
160
|
+
const getKlines = async (code) => {
|
|
161
|
+
if (!kc.has(code)) {
|
|
162
|
+
const p = join(dataDir, `${code}.json`);
|
|
163
|
+
if (existsSync(p)) {
|
|
164
|
+
try { kc.set(code, JSON.parse(readFileSync(p, "utf8")).klines); } catch { kc.set(code, null); }
|
|
165
|
+
} else {
|
|
166
|
+
try {
|
|
167
|
+
const kl = await fetchKlines(prefixed(code), 700, "hfq"); // 腾讯后复权
|
|
168
|
+
mkdirSync(dataDir, { recursive: true });
|
|
169
|
+
writeFileSync(p, JSON.stringify({ code, klines: kl, source: "tencent-hfq" }));
|
|
170
|
+
kc.set(code, kl);
|
|
171
|
+
// 指纹用文件自身的 mtime(statSync),与后续扫描读到的值严格一致——
|
|
172
|
+
// 若用 Date.now() 可能差 1ms,导致下一次请求误判"数据已变"而重复重算。
|
|
173
|
+
const st = statSync(p).mtimeMs;
|
|
174
|
+
if (dataStamp == null || st > dataStamp) dataStamp = Math.round(st);
|
|
175
|
+
} catch { kc.set(code, null); }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return kc.get(code);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// 逐股数据指纹:data/<code>.json 的 mtime(惰性 stat,memo 缓存)。
|
|
182
|
+
// 评分缓存据此逐股判定"数据未变/追加/整体替换"(见 backtest.js rowInfoValid)。
|
|
183
|
+
const stampCache = new Map();
|
|
184
|
+
const getStamp = (code) => {
|
|
185
|
+
if (stampCache.has(code)) return stampCache.get(code);
|
|
186
|
+
let v = null;
|
|
187
|
+
try {
|
|
188
|
+
const p = join(dataDir, `${code}.json`);
|
|
189
|
+
if (existsSync(p)) v = Math.round(statSync(p).mtimeMs);
|
|
190
|
+
} catch { v = null; }
|
|
191
|
+
stampCache.set(code, v);
|
|
192
|
+
return v;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// 指数(择时)
|
|
196
|
+
let getIndexKlines = null;
|
|
197
|
+
const idxP = join(cacheRoot(), "csi300.json");
|
|
198
|
+
if (existsSync(idxP)) {
|
|
199
|
+
try { getIndexKlines = () => JSON.parse(readFileSync(idxP, "utf8")); } catch { getIndexKlines = null; }
|
|
200
|
+
}
|
|
201
|
+
if (!getIndexKlines) {
|
|
202
|
+
try {
|
|
203
|
+
const ik = await fetchKlines("sh000300", 700, "hfq");
|
|
204
|
+
mkdirSync(cacheRoot(), { recursive: true });
|
|
205
|
+
writeFileSync(idxP, JSON.stringify(ik));
|
|
206
|
+
getIndexKlines = () => ik;
|
|
207
|
+
} catch { getIndexKlines = null; }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 评分缓存读写
|
|
211
|
+
const getScores = (h) => {
|
|
212
|
+
const p = scoresPath(h);
|
|
213
|
+
if (!existsSync(p)) return null;
|
|
214
|
+
try {
|
|
215
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
216
|
+
// 列式(cols:true)解码回嵌套行给引擎用;旧格式(行式)直接使用(下次保存自动迁移为列式)
|
|
217
|
+
return raw && raw.cols === true ? decodeColumnar(raw) : raw;
|
|
218
|
+
} catch { return null; }
|
|
219
|
+
};
|
|
220
|
+
const saveScores = (h, payload) => {
|
|
221
|
+
mkdirSync(join(cacheRoot(), "scores"), { recursive: true });
|
|
222
|
+
const p = scoresPath(h);
|
|
223
|
+
const tmp = p + ".tmp";
|
|
224
|
+
// 原子写:先写临时文件再 rename 覆盖——进程崩溃时正式文件要么是旧版要么是新版,
|
|
225
|
+
// 绝不出现截断损坏(截断会让下次请求全量重算,大池代价几分钟)。
|
|
226
|
+
writeFileSync(tmp, JSON.stringify(encodeColumnar(payload)));
|
|
227
|
+
renameSync(tmp, p);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return { getKlines, universe, getIndexKlines, getScores, saveScores, getTags, cacheRoot: cacheRoot(), getDataStamp: () => dataStamp, getStamp, snapshotDate };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 腾讯后复权拉取:fetchKlines 内部自动切换主/备用域名,均失败则放弃。code 为 6 位代码。 */
|
|
234
|
+
async function fetchHfq(code, count = 700) {
|
|
235
|
+
return fetchKlines(prefixed(code), count, "hfq");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 手动刷新:增量拉取并追加(腾讯后复权)——
|
|
240
|
+
* 读每只股票缓存最后日期,腾讯 hfq 拉最近 ~700 根窗口,去重(date > lastDate)后追加写回;
|
|
241
|
+
* 无缓存则全量初始化 ~700 根。指数同样增量追加。
|
|
242
|
+
* @param {string} poolKey 股票池 key
|
|
243
|
+
* @returns {{pool, total, ok, updated, noChange, fail, indexUpdated, cacheRoot}}
|
|
244
|
+
*/
|
|
245
|
+
export async function refreshCache(poolKey = "all", opts = {}) {
|
|
246
|
+
const pools = await loadUniversePools();
|
|
247
|
+
const pool = pools[poolKey] || pools.all;
|
|
248
|
+
const codes = pool.codes || [];
|
|
249
|
+
const dataDir = join(cacheRoot(), "data");
|
|
250
|
+
mkdirSync(dataDir, { recursive: true });
|
|
251
|
+
let ok = 0, updated = 0, noChange = 0, fail = 0;
|
|
252
|
+
const CONC = 8;
|
|
253
|
+
let idx = 0;
|
|
254
|
+
async function worker() {
|
|
255
|
+
while (idx < codes.length) {
|
|
256
|
+
const code = codes[idx++];
|
|
257
|
+
const p = join(dataDir, `${code}.json`);
|
|
258
|
+
let hasCache = false;
|
|
259
|
+
let lastDate = null;
|
|
260
|
+
try {
|
|
261
|
+
if (existsSync(p)) {
|
|
262
|
+
const old = JSON.parse(readFileSync(p, "utf8"));
|
|
263
|
+
const arr = Array.isArray(old.klines) ? old.klines : [];
|
|
264
|
+
if (arr.length) { hasCache = true; lastDate = arr[arr.length - 1].date; }
|
|
265
|
+
}
|
|
266
|
+
ok++;
|
|
267
|
+
const kl = await fetchHfq(code, 700); // fetchHfq 内部已剔除复权断层日(见 market.js)
|
|
268
|
+
if (hasCache) {
|
|
269
|
+
// 若新序列最后日期比缓存还短(复权断层被剔除 / 源数据回退)→ 全量替换,
|
|
270
|
+
// 修复缓存末尾的断层脏数据(如 2026-08-21 腾讯 hfq 基准切换导致的假跳变)
|
|
271
|
+
const freshLast = kl.length ? kl[kl.length - 1].date : null;
|
|
272
|
+
if (freshLast == null || freshLast < lastDate) {
|
|
273
|
+
writeFileSync(p, JSON.stringify({ code, klines: kl, source: "tencent-hfq" }));
|
|
274
|
+
updated++;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const fresh = kl.filter((r) => r.date > lastDate);
|
|
278
|
+
if (!fresh.length) { noChange++; continue; }
|
|
279
|
+
const old = JSON.parse(readFileSync(p, "utf8")).klines || [];
|
|
280
|
+
writeFileSync(p, JSON.stringify({ code, klines: old.concat(fresh), source: "tencent-hfq" }));
|
|
281
|
+
updated++;
|
|
282
|
+
} else {
|
|
283
|
+
writeFileSync(p, JSON.stringify({ code, klines: kl, source: "tencent-hfq" }));
|
|
284
|
+
updated++;
|
|
285
|
+
}
|
|
286
|
+
} catch { fail++; }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await Promise.all(Array.from({ length: CONC }, worker));
|
|
290
|
+
|
|
291
|
+
// 指数增量追加(腾讯 hfq;指数无分红复权差异)
|
|
292
|
+
let idxUpdated = false;
|
|
293
|
+
const idxP = join(cacheRoot(), "csi300.json");
|
|
294
|
+
try {
|
|
295
|
+
if (existsSync(idxP)) {
|
|
296
|
+
const old = JSON.parse(readFileSync(idxP, "utf8"));
|
|
297
|
+
const arr = Array.isArray(old) ? old : (Array.isArray(old.klines) ? old.klines : []);
|
|
298
|
+
const lastDate = arr.length ? arr[arr.length - 1].date : null;
|
|
299
|
+
if (lastDate) {
|
|
300
|
+
const ik = await fetchKlines("sh000300", 700, "hfq"); // 指数固定 sh 前缀
|
|
301
|
+
const fresh = ik.filter((r) => r.date > lastDate);
|
|
302
|
+
if (fresh.length) {
|
|
303
|
+
writeFileSync(idxP, JSON.stringify(arr.concat(fresh)));
|
|
304
|
+
idxUpdated = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch { /* 指数刷新失败不影响个股结果 */ }
|
|
309
|
+
|
|
310
|
+
return { pool: poolKey, total: codes.length, ok, updated, noChange, fail, indexUpdated: idxUpdated, cacheRoot: cacheRoot() };
|
|
311
|
+
}
|