@kamuira/stock-analyzer 1.2.6 → 1.3.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/README.md +107 -10
- package/analyze.js +33 -12
- package/backtest.js +80 -24
- package/fundamentals.js +79 -0
- package/http-util.js +37 -0
- package/index.html +309 -6
- package/news.js +109 -0
- package/package.json +10 -3
- package/server.js +178 -10
- package/valuation.js +83 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @kamuira/stock-analyzer
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A股/台股综合分析工具 — **技术面 + 估值 + 基本面 + 消息面** 四维评分、批量排名、含成本的事件驱动回测、可解释的买卖建议
|
|
4
4
|
|
|
5
5
|
## 快速开始
|
|
6
6
|
|
|
@@ -24,6 +24,35 @@ npx @kamuira/stock-analyzer
|
|
|
24
24
|
| `stock-analyze <code>` | CLI 单股分析,输出综合评分和买卖条件 |
|
|
25
25
|
| `stock-backtest <code>` | 历史回测,验证策略在该股票上的真实表现 |
|
|
26
26
|
|
|
27
|
+
## v1.3 关键改进 ⭐⭐
|
|
28
|
+
|
|
29
|
+
### 1. 四维综合评分(从纯技术 → 多维度)
|
|
30
|
+
|
|
31
|
+
除原有的技术面评分外,新增三个独立维度,各产出 0~100 分:
|
|
32
|
+
|
|
33
|
+
- **估值**:PE/PB **历史分位**(越便宜分越高)+ PEG + 行业,数据来自东方财富 F10
|
|
34
|
+
- **基本面**:ROE、营收同比、扣非净利同比、毛利率、资产负债率(最新财报)
|
|
35
|
+
- **消息面**:东方财富公告标题关键词初筛(减持/问询=利空,预增/回购/中标=利好),带上下文护栏(排除"股权激励回购"等伪利好),时间衰减加权
|
|
36
|
+
|
|
37
|
+
**综合分 = 技术 / 估值 / 基本面 / 消息 四维等权平均**,缺失维度按现有维度平均。三个新维度都是"当前快照",**不参与回测**(免费源无历史时点数据)。
|
|
38
|
+
|
|
39
|
+
### 2. 批量评分排名 + 买入候选筛选(Web)
|
|
40
|
+
|
|
41
|
+
- **批量排名**:对一组股票按综合分排序,每只并列显示四维分 + 回测复利/最大回撤,⭐ 标记"回测攻守兼备"
|
|
42
|
+
- **买入候选**:在排名基础上叠加硬性门槛(综合≥55 · 基本面≥45 · 估值≥40 · 消息面无明显利空 · 当前技术=买入侧 · 回测⭐),只有全满足才入选,**不足三只不硬凑**
|
|
43
|
+
- **自定义股票池**:输入框追加任意代码/中文名,自动并入默认清单,存本机
|
|
44
|
+
|
|
45
|
+
### 3. 回测加入交易成本 + 风险指标
|
|
46
|
+
|
|
47
|
+
- 每笔扣 **佣金(万3)+ 印花税(0.05%,卖)+ 滑点(0.1%)**,单次往返约 0.31%(可配置);`--gross` 跑无成本对比
|
|
48
|
+
- 新增 **复利累计收益、最大回撤、盈利因子**(全部基于扣费后净收益)
|
|
49
|
+
|
|
50
|
+
### 4. 健壮性修复
|
|
51
|
+
|
|
52
|
+
- 服务仅绑定 **127.0.0.1**(不再暴露到局域网)
|
|
53
|
+
- 所有外部行情请求加 **8 秒超时**(上游卡住不再永久挂起)
|
|
54
|
+
- Web 端分析的 K 线下限从 30 提到 **60**,与 CLI 一致(避免 MA60/MACD 在数据不足时出错)
|
|
55
|
+
|
|
27
56
|
## v1.2 关键改进 ⭐
|
|
28
57
|
|
|
29
58
|
### 1. 回测验证的就是用户实际用的策略
|
|
@@ -53,6 +82,8 @@ v1.1 起,所有评分逻辑统一在 `scoring.js`,**回测和实盘共享同一
|
|
|
53
82
|
|
|
54
83
|
## 功能特性
|
|
55
84
|
|
|
85
|
+
- **四维综合评分**:技术面 + 估值(PE/PB 历史分位)+ 基本面(ROE/增速/负债)+ 消息面(公告关键词)
|
|
86
|
+
- **批量评分排名 + 买入候选筛选**(Web):自定义股票池,按综合分排序,叠加硬性门槛筛出候选
|
|
56
87
|
- **15+ 技术指标**综合评分(MA、MACD、RSI、KDJ、布林带、ADX、ATR、VWAP)
|
|
57
88
|
- **动态加权评分**:趋势市加大 MA/MACD/ADX 权重,震荡市加大 RSI/KDJ/布林权重
|
|
58
89
|
- **多重信号确认**:金叉/死叉需要量能 + 趋势方向确认,减少假信号
|
|
@@ -64,9 +95,9 @@ v1.1 起,所有评分逻辑统一在 `scoring.js`,**回测和实盘共享同一
|
|
|
64
95
|
- **大盘环境过滤**(获取上证趋势,弱势时降低做多权重)
|
|
65
96
|
- **基于实际支撑/压力位的风险收益比**计算
|
|
66
97
|
- **可操作的买卖建议**(具体价位、仓位比例、持仓周期)
|
|
67
|
-
-
|
|
68
|
-
- 支持 A股 + 台股
|
|
69
|
-
- 端口自动重试 + 自动打开浏览器
|
|
98
|
+
- **含成本的事件驱动历史回测**(佣金/印花税/滑点、复利收益、最大回撤、盈利因子、可选跟踪止损)
|
|
99
|
+
- 支持 A股 + 台股(估值/基本面/消息面仅 A 股)
|
|
100
|
+
- 端口自动重试 + 自动打开浏览器(仅监听 127.0.0.1)
|
|
70
101
|
|
|
71
102
|
## 使用方式
|
|
72
103
|
|
|
@@ -77,7 +108,14 @@ stock-server # 自动打开浏览器
|
|
|
77
108
|
node dev.js # 开发模式:文件变化自动重启
|
|
78
109
|
```
|
|
79
110
|
|
|
80
|
-
启动后访问 `http://127.0.0.1:3000
|
|
111
|
+
启动后访问 `http://127.0.0.1:3000`(仅本机),端口被占用会自动 +1 重试。
|
|
112
|
+
|
|
113
|
+
Web 界面除单股分析外,还提供 **「📊 批量评分排名」**:
|
|
114
|
+
|
|
115
|
+
- 默认对内置自选股(27 只)跑四维综合评分,按综合分排序
|
|
116
|
+
- 顶部输入框可追加任意代码/中文名(自动并入默认池,存本机)
|
|
117
|
+
- 结果含 **买入候选**卡片(只列同时满足全部硬性条件的票)+ 完整排名表(四维分 + 回测复利)
|
|
118
|
+
- 点任意一行进入该股的完整四维详情(技术指标拆解 + 估值/基本面/消息面明细 + 回测)
|
|
81
119
|
|
|
82
120
|
### CLI 分析
|
|
83
121
|
|
|
@@ -97,6 +135,7 @@ stock-backtest sh603986 # 默认:TP1 止盈 + 反向信号
|
|
|
97
135
|
stock-backtest sh603986 --trailing # 启用跟踪止损(2.5×ATR)
|
|
98
136
|
stock-backtest sh603986 --trailing --trailing-atr=1.5
|
|
99
137
|
stock-backtest sh603986 --trailing --no-tp1 # 纯趋势跟随(只靠跟踪止损)
|
|
138
|
+
stock-backtest sh603986 --gross # 不计交易成本(与含成本版对比)
|
|
100
139
|
stock-backtest all # 回测全部关注列表
|
|
101
140
|
```
|
|
102
141
|
|
|
@@ -164,6 +203,28 @@ stock-backtest all # 回测全部关注列表
|
|
|
164
203
|
|
|
165
204
|
`≥ 2.0` 标记"性价比高",`< 1.0` 标记"建议等回调"。
|
|
166
205
|
|
|
206
|
+
### 四维综合分(批量排名用)
|
|
207
|
+
|
|
208
|
+
技术面评分先标准化到 0~100,与另外三个维度(各 0~100)**等权平均**得到综合分:
|
|
209
|
+
|
|
210
|
+
| 维度 | 0~100 含义 | 说明 |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| 技术 | 评分标准化 | 择时,越高越偏多 |
|
|
213
|
+
| 估值 | 越便宜越高 | PE/PB 历史分位 + PEG |
|
|
214
|
+
| 基本面 | 越优质越高 | ROE/营收·利润增速/负债 |
|
|
215
|
+
| 消息 | 50 中性 | 公告关键词:利好 + / 利空 − |
|
|
216
|
+
|
|
217
|
+
### 买入候选门槛
|
|
218
|
+
|
|
219
|
+
排名页"买入候选"在综合分之上叠加硬性条件,**全部满足**才入选:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
综合 ≥ 55 · 基本面 ≥ 45(不弱) · 估值 ≥ 40(不高估)
|
|
223
|
+
· 消息面无明显利空(≥ 40) · 当前技术 = 买入侧(评分 ≥ 1) · 回测 ⭐(攻守兼备)
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
⭐ = 盈利因子 ≥ 1.5 且 最大回撤 ≤ 25% 且 复利收益 > 0。不足三只不硬凑,零只时提示宁可空仓。
|
|
227
|
+
|
|
167
228
|
## 事件驱动回测
|
|
168
229
|
|
|
169
230
|
### 退出逻辑(优先级)
|
|
@@ -189,9 +250,27 @@ stock-backtest all # 回测全部关注列表
|
|
|
189
250
|
|
|
190
251
|
原因:跟踪距离 = `2.5 × ATR`。ATR 大 → 回撤容忍 15%+ → 利润吐回;ATR 小 → 紧密跟踪 → 锁住利润。**自己用 `--trailing` 跑一遍,看哪些股票适合**。
|
|
191
252
|
|
|
253
|
+
### 交易成本与风险指标
|
|
254
|
+
|
|
255
|
+
每笔交易扣除成本后再统计(默认值,均为单边比例,可在 `backtest()` options 配置):
|
|
256
|
+
|
|
257
|
+
| 项目 | 默认 | 说明 |
|
|
258
|
+
|---|---|---|
|
|
259
|
+
| 佣金 | 0.03% | 万3,买卖各收 |
|
|
260
|
+
| 印花税 | 0.05% | 仅卖出收 |
|
|
261
|
+
| 滑点 | 0.1% | 模拟非理想成交,买卖各一次 |
|
|
262
|
+
| **单次往返** | **≈ 0.31%** | 用 `--gross` 可关闭成本做对比 |
|
|
263
|
+
|
|
264
|
+
汇总统计(全部基于**扣费后净收益**):
|
|
265
|
+
|
|
266
|
+
- **胜率 / 平均净收益 / 等权累计**
|
|
267
|
+
- **复利累计收益**:满仓滚动净值增长(不再是简单相加)
|
|
268
|
+
- **最大回撤**:复利净值从峰值到谷底的最大跌幅
|
|
269
|
+
- **盈利因子**:净盈利总和 ÷ 净亏损总和(>1 才是正期望)
|
|
270
|
+
|
|
192
271
|
### 评级标准
|
|
193
272
|
|
|
194
|
-
| 评级 | 条件 |
|
|
273
|
+
| 评级 | 条件(净收益口径) |
|
|
195
274
|
|------|------|
|
|
196
275
|
| 优秀 | 胜率 ≥ 60% 且均收益 > 3% |
|
|
197
276
|
| 良好 | 胜率 ≥ 55% 且均收益 > 1.5% |
|
|
@@ -200,7 +279,9 @@ stock-backtest all # 回测全部关注列表
|
|
|
200
279
|
|
|
201
280
|
### 当前局限
|
|
202
281
|
|
|
203
|
-
- ❌
|
|
282
|
+
- ❌ 未模拟 A 股涨跌停(止损价落在跌停板内实盘卖不出)
|
|
283
|
+
- ❌ 估值/基本面/消息面为当前快照,**不参与回测**(免费源无历史时点数据)
|
|
284
|
+
- ❌ 消息面是公告关键词初筛,**较粗**,读不懂语义、不含新闻舆情/研报
|
|
204
285
|
- ❌ 单股回测,无多周期共振(日+周+月)
|
|
205
286
|
- ❌ 无信号日志/复盘(每次回测都重新算)
|
|
206
287
|
|
|
@@ -212,15 +293,22 @@ stock-backtest all # 回测全部关注列表
|
|
|
212
293
|
| 历史K线 | 腾讯财经 (web.ifzq.gtimg.cn) | Yahoo Finance |
|
|
213
294
|
| 股票搜索 | 腾讯智能搜索 (smartbox.gtimg.cn) | — |
|
|
214
295
|
| 大盘指数 | 腾讯财经(上证 000001) | — |
|
|
296
|
+
| 估值(PE/PB分位) | 东方财富 F10 (datacenter.eastmoney.com) | — |
|
|
297
|
+
| 基本面(财务) | 东方财富 F10 主要指标 | — |
|
|
298
|
+
| 消息面(公告) | 东方财富公告 (np-anotice-stock.eastmoney.com) | — |
|
|
215
299
|
|
|
216
300
|
## 项目结构
|
|
217
301
|
|
|
218
302
|
```
|
|
219
303
|
├── indicators.js # 技术指标:SMA/EMA/MACD/RSI/KDJ/BOLL/ATR/ADX/ZigZag 等
|
|
220
|
-
├── scoring.js #
|
|
304
|
+
├── scoring.js # 技术面评分逻辑(唯一来源,被 analyze/backtest/server 共享)
|
|
305
|
+
├── valuation.js # 估值维度(PE/PB 历史分位,东方财富)
|
|
306
|
+
├── fundamentals.js # 基本面维度(ROE/增速/负债,东方财富)
|
|
307
|
+
├── news.js # 消息面维度(公告关键词初筛 + 缓存)
|
|
308
|
+
├── http-util.js # 带超时的 JSON 拉取 + secid 转换
|
|
221
309
|
├── analyze.js # CLI 单股分析 + 数据获取(A股+台股)
|
|
222
|
-
├── backtest.js #
|
|
223
|
-
├── server.js # Web 服务(API + 静态文件)
|
|
310
|
+
├── backtest.js # 含成本的事件驱动回测引擎
|
|
311
|
+
├── server.js # Web 服务(分析/回测/排名 API + 静态文件)
|
|
224
312
|
├── dev.js # 开发模式(文件变化自动重启)
|
|
225
313
|
├── index.html # 前端页面
|
|
226
314
|
└── bin/ # CLI 入口
|
|
@@ -237,6 +325,15 @@ stock-backtest all # 回测全部关注列表
|
|
|
237
325
|
|
|
238
326
|
## 更新日志
|
|
239
327
|
|
|
328
|
+
### v1.3.0
|
|
329
|
+
- **四维综合评分**:新增估值(`valuation.js`)、基本面(`fundamentals.js`)、消息面(`news.js`)三个维度,综合分 = 技术/估值/基本面/消息 等权
|
|
330
|
+
- **批量评分排名 + 买入候选**(Web):自定义股票池、四维分列、⭐ 回测攻守兼备、买入候选硬性门槛筛选、TOP 卡片
|
|
331
|
+
- **回测加入交易成本**(佣金/印花税/滑点)+ 复利累计收益、最大回撤、盈利因子;新增 `--gross`
|
|
332
|
+
- 单股详情页加入估值/基本面/消息面三维卡片 + 明细
|
|
333
|
+
- 新增 5 只 CLI 自选(新金路/春秋航空/中钨高新/华电国际/璞泰来)
|
|
334
|
+
- **健壮性**:服务仅绑 127.0.0.1、外部请求 8s 超时、Web 分析 K 线下限 30→60
|
|
335
|
+
- 新增共享模块 `http-util.js`(带超时的 JSON 拉取)
|
|
336
|
+
|
|
240
337
|
### v1.2.2
|
|
241
338
|
- `package.json` 加 `repository` / `homepage` / `bugs` 字段
|
|
242
339
|
- 源码托管在 GitHub:https://github.com/guiwzh/stock-analyzer
|
package/analyze.js
CHANGED
|
@@ -12,6 +12,22 @@ const https = require('https');
|
|
|
12
12
|
const { SMA } = require('./indicators');
|
|
13
13
|
const { computeScore } = require('./scoring');
|
|
14
14
|
|
|
15
|
+
// 外部行情接口的单次请求超时(毫秒)。上游(新浪/腾讯/Yahoo)任一卡住时,
|
|
16
|
+
// 不加超时会让 Promise 永久挂起,前端一直转圈。
|
|
17
|
+
const REQUEST_TIMEOUT = 8000;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 给一个 http(s) 请求挂上超时和错误处理。
|
|
21
|
+
* @param {import('http').ClientRequest} req http(s).get 返回的请求对象
|
|
22
|
+
* @param {(err: Error) => void} reject Promise 的 reject
|
|
23
|
+
* @returns {import('http').ClientRequest} 原 req,便于链式
|
|
24
|
+
*/
|
|
25
|
+
function withTimeout(req, reject) {
|
|
26
|
+
req.setTimeout(REQUEST_TIMEOUT, () => req.destroy(new Error('请求超时')));
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
return req;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
const WATCH_LIST = {
|
|
16
32
|
'sz002049': '紫光国微',
|
|
17
33
|
'sh603893': '瑞芯微',
|
|
@@ -35,6 +51,11 @@ const WATCH_LIST = {
|
|
|
35
51
|
'sh600111': '北方稀土',
|
|
36
52
|
'sh601318': '中国平安',
|
|
37
53
|
'sh601066': '中信建投',
|
|
54
|
+
'sz000510': '新金路',
|
|
55
|
+
'sh601021': '春秋航空',
|
|
56
|
+
'sz000657': '中钨高新',
|
|
57
|
+
'sh600027': '华电国际',
|
|
58
|
+
'sh603659': '璞泰来',
|
|
38
59
|
};
|
|
39
60
|
|
|
40
61
|
// ==================== 数据获取 ====================
|
|
@@ -50,7 +71,7 @@ function fetchRealtime(codes) {
|
|
|
50
71
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
51
72
|
},
|
|
52
73
|
};
|
|
53
|
-
http.get(options, (res) => {
|
|
74
|
+
withTimeout(http.get(options, (res) => {
|
|
54
75
|
const chunks = [];
|
|
55
76
|
res.on('data', c => chunks.push(c));
|
|
56
77
|
res.on('end', () => {
|
|
@@ -81,23 +102,23 @@ function fetchRealtime(codes) {
|
|
|
81
102
|
}
|
|
82
103
|
resolve(results);
|
|
83
104
|
});
|
|
84
|
-
})
|
|
105
|
+
}), reject);
|
|
85
106
|
});
|
|
86
107
|
}
|
|
87
108
|
|
|
88
109
|
function fetchHistory(code, days = 120) {
|
|
89
110
|
return new Promise((resolve, reject) => {
|
|
90
111
|
const url = `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${code},day,,,${days},qfq`;
|
|
91
|
-
https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
112
|
+
withTimeout(https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
92
113
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
93
114
|
const client = res.headers.location.startsWith('https') ? https : http;
|
|
94
|
-
client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
115
|
+
withTimeout(client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
95
116
|
collect(res2, code, resolve, reject);
|
|
96
|
-
})
|
|
117
|
+
}), reject);
|
|
97
118
|
return;
|
|
98
119
|
}
|
|
99
120
|
collect(res, code, resolve, reject);
|
|
100
|
-
})
|
|
121
|
+
}), reject);
|
|
101
122
|
});
|
|
102
123
|
}
|
|
103
124
|
|
|
@@ -128,7 +149,7 @@ function fetchTWRealtime(code) {
|
|
|
128
149
|
const num = code.replace(/^tw/i, '');
|
|
129
150
|
const exCh = `tse_${num}.tw|otc_${num}.tw`;
|
|
130
151
|
const url = `/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
|
|
131
|
-
https.get({ hostname: 'mis.twse.com.tw', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
152
|
+
withTimeout(https.get({ hostname: 'mis.twse.com.tw', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
132
153
|
const chunks = [];
|
|
133
154
|
res.on('data', c => chunks.push(c));
|
|
134
155
|
res.on('end', () => {
|
|
@@ -153,7 +174,7 @@ function fetchTWRealtime(code) {
|
|
|
153
174
|
resolve(results);
|
|
154
175
|
} catch (e) { reject(e); }
|
|
155
176
|
});
|
|
156
|
-
})
|
|
177
|
+
}), reject);
|
|
157
178
|
});
|
|
158
179
|
}
|
|
159
180
|
|
|
@@ -164,15 +185,15 @@ function fetchTWHistory(code, days = 120) {
|
|
|
164
185
|
const period2 = Math.floor(Date.now() / 1000);
|
|
165
186
|
const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
|
|
166
187
|
const url = `/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
|
|
167
|
-
https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
188
|
+
withTimeout(https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
|
|
168
189
|
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
169
|
-
https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
190
|
+
withTimeout(https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
|
|
170
191
|
collectTWHist(res2, resolve, reject);
|
|
171
|
-
})
|
|
192
|
+
}), reject);
|
|
172
193
|
return;
|
|
173
194
|
}
|
|
174
195
|
collectTWHist(res, resolve, reject);
|
|
175
|
-
})
|
|
196
|
+
}), reject);
|
|
176
197
|
});
|
|
177
198
|
}
|
|
178
199
|
|
package/backtest.js
CHANGED
|
@@ -70,11 +70,33 @@ function backtest(klines, options = {}) {
|
|
|
70
70
|
trailing = false, // 默认关闭(高波动股开了反而拖累累计收益)
|
|
71
71
|
trailingATR = 2.5,
|
|
72
72
|
useTP1 = true,
|
|
73
|
+
// ===== 交易成本(A股零售默认值,均为单边比例) =====
|
|
74
|
+
commission = 0.0003, // 佣金 万3(双边各收)
|
|
75
|
+
stampDuty = 0.0005, // 印花税 0.05%(仅卖出收)
|
|
76
|
+
slippage = 0.001, // 滑点 0.1%(买卖各一次,模拟非理想成交)
|
|
73
77
|
} = options;
|
|
74
78
|
const n = klines.length;
|
|
75
79
|
const trades = [];
|
|
76
80
|
let position = null;
|
|
77
81
|
|
|
82
|
+
// 买入腿成本 = 佣金 + 滑点;卖出腿成本 = 佣金 + 印花税 + 滑点
|
|
83
|
+
const buyCost = commission + slippage;
|
|
84
|
+
const sellCost = commission + stampDuty + slippage;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 给定一笔交易的入场/出场价,返回毛收益和扣费后净收益(均为百分比)
|
|
88
|
+
* 做多:买入摊高成本、卖出折损;做空:入场为卖、出场为买,成本对称施加。
|
|
89
|
+
*/
|
|
90
|
+
function computeReturns(type, entryPrice, exitPrice) {
|
|
91
|
+
const gross = type === 'long'
|
|
92
|
+
? (exitPrice - entryPrice) / entryPrice * 100
|
|
93
|
+
: (entryPrice - exitPrice) / entryPrice * 100;
|
|
94
|
+
const net = type === 'long'
|
|
95
|
+
? ((exitPrice * (1 - sellCost)) / (entryPrice * (1 + buyCost)) - 1) * 100
|
|
96
|
+
: ((1 - sellCost) - (exitPrice / entryPrice) * (1 + buyCost)) * 100;
|
|
97
|
+
return { gross: +gross.toFixed(2), net: +net.toFixed(2) };
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
for (let i = startIdx; i < n; i++) {
|
|
79
101
|
const today = klines[i];
|
|
80
102
|
const mEnv = indexKlines ? getMarketEnvAtDate(indexKlines, today.date) : null;
|
|
@@ -163,16 +185,15 @@ function backtest(klines, options = {}) {
|
|
|
163
185
|
}
|
|
164
186
|
|
|
165
187
|
if (exitReason) {
|
|
166
|
-
const
|
|
167
|
-
? (exitPrice - position.entryPrice) / position.entryPrice * 100
|
|
168
|
-
: (position.entryPrice - exitPrice) / position.entryPrice * 100;
|
|
188
|
+
const ret = computeReturns(position.type, position.entryPrice, exitPrice);
|
|
169
189
|
trades.push({
|
|
170
190
|
type: position.type,
|
|
171
191
|
entryIdx: position.entryIdx, entryDate: position.entryDate, entryPrice: position.entryPrice,
|
|
172
192
|
entryScore: position.entryScore,
|
|
173
193
|
exitIdx: i, exitDate: today.date, exitPrice: +exitPrice.toFixed(2),
|
|
174
194
|
exitReason,
|
|
175
|
-
returnPct:
|
|
195
|
+
returnPct: ret.gross,
|
|
196
|
+
netReturnPct: ret.net,
|
|
176
197
|
holdDays: i - position.entryIdx,
|
|
177
198
|
stopLoss: position.stopLoss, takeProfit1: position.takeProfit1,
|
|
178
199
|
});
|
|
@@ -184,22 +205,26 @@ function backtest(klines, options = {}) {
|
|
|
184
205
|
if (position) {
|
|
185
206
|
const last = n - 1;
|
|
186
207
|
const exitPrice = klines[last].close;
|
|
187
|
-
const
|
|
188
|
-
? (exitPrice - position.entryPrice) / position.entryPrice * 100
|
|
189
|
-
: (position.entryPrice - exitPrice) / position.entryPrice * 100;
|
|
208
|
+
const ret = computeReturns(position.type, position.entryPrice, exitPrice);
|
|
190
209
|
trades.push({
|
|
191
210
|
type: position.type,
|
|
192
211
|
entryIdx: position.entryIdx, entryDate: position.entryDate, entryPrice: position.entryPrice,
|
|
193
212
|
entryScore: position.entryScore,
|
|
194
213
|
exitIdx: last, exitDate: klines[last].date, exitPrice: +exitPrice.toFixed(2),
|
|
195
214
|
exitReason: 'endOfData',
|
|
196
|
-
returnPct:
|
|
215
|
+
returnPct: ret.gross,
|
|
216
|
+
netReturnPct: ret.net,
|
|
197
217
|
holdDays: last - position.entryIdx,
|
|
198
218
|
stopLoss: position.stopLoss, takeProfit1: position.takeProfit1,
|
|
199
219
|
});
|
|
200
220
|
}
|
|
201
221
|
|
|
202
|
-
|
|
222
|
+
const result = summarize(trades, klines);
|
|
223
|
+
result.costs = {
|
|
224
|
+
commission, stampDuty, slippage,
|
|
225
|
+
roundTripPct: +((2 * commission + stampDuty + 2 * slippage) * 100).toFixed(3), // 一买一卖的总成本估算
|
|
226
|
+
};
|
|
227
|
+
return result;
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
// ==================== 汇总统计 ====================
|
|
@@ -208,29 +233,50 @@ function summarize(trades, klines) {
|
|
|
208
233
|
const longs = trades.filter(t => t.type === 'long');
|
|
209
234
|
const shorts = trades.filter(t => t.type === 'short');
|
|
210
235
|
|
|
236
|
+
// 所有统计均基于扣费后的净收益 netReturnPct
|
|
211
237
|
function stats(group) {
|
|
212
238
|
if (group.length === 0) return null;
|
|
213
|
-
const wins = group.filter(t => t.
|
|
214
|
-
const losses = group.filter(t => t.
|
|
215
|
-
const returns = group.map(t => t.
|
|
239
|
+
const wins = group.filter(t => t.netReturnPct > 0);
|
|
240
|
+
const losses = group.filter(t => t.netReturnPct <= 0);
|
|
241
|
+
const returns = group.map(t => t.netReturnPct);
|
|
242
|
+
const grossReturns = group.map(t => t.returnPct);
|
|
216
243
|
const holdDaysArr = group.map(t => t.holdDays);
|
|
217
244
|
const sum = returns.reduce((a, b) => a + b, 0);
|
|
218
245
|
const reasonCount = {};
|
|
219
246
|
for (const t of group) reasonCount[t.exitReason] = (reasonCount[t.exitReason] || 0) + 1;
|
|
247
|
+
|
|
248
|
+
// 复利净值曲线 → 复利累计收益 + 最大回撤(按该组交易的时间先后顺序)
|
|
249
|
+
let equity = 1, peak = 1, maxDD = 0;
|
|
250
|
+
for (const t of group) {
|
|
251
|
+
equity *= (1 + t.netReturnPct / 100);
|
|
252
|
+
if (equity > peak) peak = equity;
|
|
253
|
+
const dd = (peak - equity) / peak;
|
|
254
|
+
if (dd > maxDD) maxDD = dd;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// 盈利因子:净盈利总和 / 净亏损总和(绝对值)。无亏损时封顶 999 避免 JSON 里的 Infinity
|
|
258
|
+
const winSum = wins.reduce((a, t) => a + t.netReturnPct, 0);
|
|
259
|
+
const lossSumAbs = Math.abs(losses.reduce((a, t) => a + t.netReturnPct, 0));
|
|
260
|
+
const profitFactor = lossSumAbs > 0 ? +(winSum / lossSumAbs).toFixed(2) : (winSum > 0 ? 999 : 0);
|
|
261
|
+
|
|
220
262
|
return {
|
|
221
263
|
total: group.length,
|
|
222
264
|
wins: wins.length,
|
|
223
265
|
losses: losses.length,
|
|
224
266
|
winRate: +(wins.length / group.length * 100).toFixed(1),
|
|
225
267
|
avgReturn: +(sum / group.length).toFixed(2),
|
|
226
|
-
totalReturn: +sum.toFixed(2),
|
|
268
|
+
totalReturn: +sum.toFixed(2), // 净收益简单相加(等权)
|
|
269
|
+
compoundReturn: +((equity - 1) * 100).toFixed(2), // 净收益复利(满仓滚动)
|
|
270
|
+
maxDrawdown: +(maxDD * 100).toFixed(2), // 复利净值的最大回撤
|
|
271
|
+
profitFactor,
|
|
272
|
+
grossAvgReturn: +(grossReturns.reduce((a, b) => a + b, 0) / group.length).toFixed(2), // 未扣费均收益,参考用
|
|
227
273
|
maxWin: +Math.max(...returns).toFixed(2),
|
|
228
274
|
maxLoss: +Math.min(...returns).toFixed(2),
|
|
229
275
|
avgHoldDays: +(holdDaysArr.reduce((a, b) => a + b, 0) / group.length).toFixed(1),
|
|
230
276
|
exitReasons: reasonCount,
|
|
231
|
-
// 期望值 / 凯利公式所需的盈亏比
|
|
232
|
-
avgWin: wins.length > 0 ? +(
|
|
233
|
-
avgLoss: losses.length > 0 ? +(losses.reduce((a, t) => a + t.
|
|
277
|
+
// 期望值 / 凯利公式所需的盈亏比(净)
|
|
278
|
+
avgWin: wins.length > 0 ? +(winSum / wins.length).toFixed(2) : 0,
|
|
279
|
+
avgLoss: losses.length > 0 ? +(losses.reduce((a, t) => a + t.netReturnPct, 0) / losses.length).toFixed(2) : 0,
|
|
234
280
|
};
|
|
235
281
|
}
|
|
236
282
|
|
|
@@ -265,9 +311,10 @@ function formatBacktestReport(code, klines, btResult) {
|
|
|
265
311
|
if (!s) return;
|
|
266
312
|
lines.push('');
|
|
267
313
|
lines.push('─'.repeat(64));
|
|
268
|
-
lines.push(`【${title}
|
|
314
|
+
lines.push(`【${title}】(以下均为扣费后净收益)`);
|
|
269
315
|
lines.push(` 交易笔数: ${s.total} 胜: ${s.wins} 负: ${s.losses} 胜率: ${s.winRate}%`);
|
|
270
|
-
lines.push(` 平均收益: ${s.avgReturn}%
|
|
316
|
+
lines.push(` 平均收益: ${s.avgReturn}%(毛 ${s.grossAvgReturn}%) 等权累计: ${s.totalReturn}% 复利累计: ${s.compoundReturn}%`);
|
|
317
|
+
lines.push(` 最大回撤: ${s.maxDrawdown}% 盈利因子: ${s.profitFactor}`);
|
|
271
318
|
lines.push(` 平均盈利: ${s.avgWin}% 平均亏损: ${s.avgLoss}% 盈亏比: ${s.avgLoss < 0 ? Math.abs(s.avgWin / s.avgLoss).toFixed(2) : '∞'}`);
|
|
272
319
|
lines.push(` 最大盈利: ${s.maxWin}% 最大亏损: ${s.maxLoss}% 平均持有: ${s.avgHoldDays} 天`);
|
|
273
320
|
const reasonLabel = { stopLoss: '止损', trailingStop: '跟踪止损', takeProfit: '止盈', reverseSignal: '反向信号', timeout: '超时', endOfData: '数据末尾' };
|
|
@@ -286,8 +333,9 @@ function formatBacktestReport(code, klines, btResult) {
|
|
|
286
333
|
const reasonLabel = { stopLoss: '止损', trailingStop: '跟踪止损', takeProfit: '止盈', reverseSignal: '反向', timeout: '超时', endOfData: '末尾' };
|
|
287
334
|
for (const t of trades.slice(-10)) {
|
|
288
335
|
const sign = t.type === 'long' ? '▲多' : '▼空';
|
|
289
|
-
const
|
|
290
|
-
|
|
336
|
+
const net = t.netReturnPct;
|
|
337
|
+
const r = net >= 0 ? `+${net}%` : `${net}%`;
|
|
338
|
+
lines.push(` ${t.entryDate} ${sign} @${t.entryPrice} → ${t.exitDate} @${t.exitPrice} | 净${r} | ${t.holdDays}天 | ${reasonLabel[t.exitReason] || t.exitReason}`);
|
|
291
339
|
}
|
|
292
340
|
|
|
293
341
|
// 综合评级
|
|
@@ -302,7 +350,11 @@ function formatBacktestReport(code, klines, btResult) {
|
|
|
302
350
|
lines.push(` 做多评级: 胜率 ${long.winRate}% / 均收益 ${long.avgReturn}% / ${long.total}笔 → ${grade}`);
|
|
303
351
|
}
|
|
304
352
|
lines.push(` 注: 用与实盘 analyze 一致的评分(scoring.js),并按 stopLoss/takeProfit 退出`);
|
|
305
|
-
|
|
353
|
+
if (btResult.costs) {
|
|
354
|
+
const c = btResult.costs;
|
|
355
|
+
lines.push(` 成本: 佣金${(c.commission*100).toFixed(3)}% 印花税${(c.stampDuty*100).toFixed(3)}%(卖) 滑点${(c.slippage*100).toFixed(3)}% → 单次往返约 ${c.roundTripPct}%`);
|
|
356
|
+
}
|
|
357
|
+
lines.push(` 局限: 未模拟涨跌停无法成交、未考虑资金分散与最小手续费`);
|
|
306
358
|
lines.push('═'.repeat(64));
|
|
307
359
|
return lines.join('\n');
|
|
308
360
|
}
|
|
@@ -318,7 +370,8 @@ function resolveCode(input) {
|
|
|
318
370
|
return input;
|
|
319
371
|
}
|
|
320
372
|
|
|
321
|
-
const WATCH_LIST = ['sz002049', 'sh603893', 'sz300750', 'sh601138', 'sh600011', 'tw2330'
|
|
373
|
+
const WATCH_LIST = ['sz002049', 'sh603893', 'sz300750', 'sh601138', 'sh600011', 'tw2330',
|
|
374
|
+
'sz000510', 'sh601021', 'sz000657', 'sh600027', 'sh603659'];
|
|
322
375
|
|
|
323
376
|
async function fetchAny(code, days) {
|
|
324
377
|
return code.startsWith('tw') ? fetchTWHistory(code, days) : fetchHistory(code, days);
|
|
@@ -326,11 +379,12 @@ async function fetchAny(code, days) {
|
|
|
326
379
|
|
|
327
380
|
function parseArgs(argv) {
|
|
328
381
|
const args = argv.slice(2);
|
|
329
|
-
const result = { code: 'all', trailing: false, trailingATR: 2.5, useTP1: true };
|
|
382
|
+
const result = { code: 'all', trailing: false, trailingATR: 2.5, useTP1: true, gross: false };
|
|
330
383
|
for (const a of args) {
|
|
331
384
|
if (a === '--trailing') result.trailing = true;
|
|
332
385
|
else if (a.startsWith('--trailing-atr=')) result.trailingATR = parseFloat(a.split('=')[1]);
|
|
333
386
|
else if (a === '--no-tp1') result.useTP1 = false;
|
|
387
|
+
else if (a === '--gross') result.gross = true; // 不计任何交易成本,用于对比
|
|
334
388
|
else if (!a.startsWith('--')) result.code = a;
|
|
335
389
|
}
|
|
336
390
|
return result;
|
|
@@ -343,7 +397,8 @@ async function main() {
|
|
|
343
397
|
const modeDesc = [];
|
|
344
398
|
if (args.trailing) modeDesc.push(`跟踪止损=${args.trailingATR}×ATR`);
|
|
345
399
|
if (!args.useTP1) modeDesc.push('无TP1');
|
|
346
|
-
|
|
400
|
+
modeDesc.push(args.gross ? '不计成本(毛收益)' : '已扣手续费+滑点');
|
|
401
|
+
if (!args.trailing && args.useTP1) modeDesc.unshift('默认: TP1止盈,无跟踪');
|
|
347
402
|
console.log(`\n正在拉取数据并回测... [${modeDesc.join(', ')}]\n`);
|
|
348
403
|
|
|
349
404
|
let indexKlines = null;
|
|
@@ -364,6 +419,7 @@ async function main() {
|
|
|
364
419
|
const btResult = backtest(klines, {
|
|
365
420
|
startIdx: 60, maxHoldDays: 30, indexKlines,
|
|
366
421
|
trailing: args.trailing, trailingATR: args.trailingATR, useTP1: args.useTP1,
|
|
422
|
+
...(args.gross ? { commission: 0, stampDuty: 0, slippage: 0 } : {}),
|
|
367
423
|
});
|
|
368
424
|
console.log(formatBacktestReport(code, klines, btResult));
|
|
369
425
|
console.log('');
|
package/fundamentals.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 基本面模块 — 数据来自东方财富 F10 主要财务指标(RPT_F10_FINANCE_MAINFINADATA)
|
|
3
|
+
*
|
|
4
|
+
* 取最近一期报告:ROE、营收同比、扣非净利润同比、毛利率、资产负债率。
|
|
5
|
+
* 打分思路:成长性(营收/利润增速) + 盈利质量(ROE/毛利) + 财务健康(负债率)。
|
|
6
|
+
*
|
|
7
|
+
* 注意:季度更新;为"当前值",不参与回测。
|
|
8
|
+
*/
|
|
9
|
+
const { getJSON, toSecuCode } = require('./http-util');
|
|
10
|
+
|
|
11
|
+
async function fetchFundamentals(code) {
|
|
12
|
+
const secu = toSecuCode(code);
|
|
13
|
+
if (!secu) return null; // 非 A 股
|
|
14
|
+
const url = 'https://datacenter.eastmoney.com/securities/api/data/v1/get'
|
|
15
|
+
+ '?reportName=RPT_F10_FINANCE_MAINFINADATA'
|
|
16
|
+
+ '&columns=SECUCODE,REPORT_DATE,ROEJQ,YYZSRGDHBZC,KCFJCXSYJLRTZ,XSMLL,ZCFZL'
|
|
17
|
+
+ `&filter=(SECUCODE%3D%22${secu}%22)`
|
|
18
|
+
+ '&pageSize=1&sortColumns=REPORT_DATE&sortTypes=-1&source=HSF10&client=PC';
|
|
19
|
+
const j = await getJSON(url, { Referer: 'https://emweb.securities.eastmoney.com/' });
|
|
20
|
+
const rows = j && j.result && j.result.data;
|
|
21
|
+
if (!rows || !rows.length) return null;
|
|
22
|
+
const r = rows[0];
|
|
23
|
+
return {
|
|
24
|
+
reportDate: (r.REPORT_DATE || '').slice(0, 10),
|
|
25
|
+
roe: r.ROEJQ, // ROE(加权)
|
|
26
|
+
revenueYoY: r.YYZSRGDHBZC, // 营业总收入同比增长
|
|
27
|
+
profitYoY: r.KCFJCXSYJLRTZ,// 扣非净利润同比增长
|
|
28
|
+
grossMargin: r.XSMLL, // 销售毛利率
|
|
29
|
+
debtRatio: r.ZCFZL, // 资产负债率
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 基本面打分,返回 0~100 + 文字信号 */
|
|
34
|
+
function scoreFundamentals(f) {
|
|
35
|
+
if (!f) return { score: null, signals: ['无财务数据(非A股或接口失败)'] };
|
|
36
|
+
const signals = [];
|
|
37
|
+
let score = 50;
|
|
38
|
+
|
|
39
|
+
if (f.roe != null) {
|
|
40
|
+
if (f.roe >= 15) { score += 20; signals.push(`ROE=${f.roe.toFixed(1)}%(优秀)`); }
|
|
41
|
+
else if (f.roe >= 8) { score += 10; signals.push(`ROE=${f.roe.toFixed(1)}%(良好)`); }
|
|
42
|
+
else if (f.roe >= 3) { score += 2; signals.push(`ROE=${f.roe.toFixed(1)}%(一般)`); }
|
|
43
|
+
else if (f.roe < 0) { score -= 20; signals.push(`ROE=${f.roe.toFixed(1)}%(亏损)`); }
|
|
44
|
+
else { signals.push(`ROE=${f.roe.toFixed(1)}%(偏低)`); }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (f.revenueYoY != null) {
|
|
48
|
+
if (f.revenueYoY >= 20) { score += 12; signals.push(`营收同比+${f.revenueYoY.toFixed(1)}%(高增长)`); }
|
|
49
|
+
else if (f.revenueYoY >= 0) { score += 4; signals.push(`营收同比+${f.revenueYoY.toFixed(1)}%`); }
|
|
50
|
+
else if (f.revenueYoY <= -20) { score -= 15; signals.push(`营收同比${f.revenueYoY.toFixed(1)}%(大幅下滑)`); }
|
|
51
|
+
else { score -= 6; signals.push(`营收同比${f.revenueYoY.toFixed(1)}%(下滑)`); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (f.profitYoY != null) {
|
|
55
|
+
if (f.profitYoY >= 30) { score += 15; signals.push(`扣非净利同比+${f.profitYoY.toFixed(1)}%(高增长)`); }
|
|
56
|
+
else if (f.profitYoY >= 0) { score += 5; signals.push(`扣非净利同比+${f.profitYoY.toFixed(1)}%`); }
|
|
57
|
+
else if (f.profitYoY <= -30) { score -= 18; signals.push(`扣非净利同比${f.profitYoY.toFixed(1)}%(大幅下滑)`); }
|
|
58
|
+
else { score -= 8; signals.push(`扣非净利同比${f.profitYoY.toFixed(1)}%(下滑)`); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (f.debtRatio != null) {
|
|
62
|
+
if (f.debtRatio <= 40) { score += 6; signals.push(`资产负债率${f.debtRatio.toFixed(1)}%(健康)`); }
|
|
63
|
+
else if (f.debtRatio <= 65) { signals.push(`资产负债率${f.debtRatio.toFixed(1)}%`); }
|
|
64
|
+
else if (f.debtRatio <= 80) { score -= 8; signals.push(`资产负债率${f.debtRatio.toFixed(1)}%(偏高)`); }
|
|
65
|
+
else { score -= 15; signals.push(`资产负债率${f.debtRatio.toFixed(1)}%(高风险)`); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (f.grossMargin != null) signals.push(`毛利率${f.grossMargin.toFixed(1)}%`);
|
|
69
|
+
if (f.reportDate) signals.push(`报告期:${f.reportDate}`);
|
|
70
|
+
|
|
71
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
72
|
+
return {
|
|
73
|
+
score, signals,
|
|
74
|
+
label: score >= 65 ? '优质' : score >= 45 ? '中等' : '偏弱',
|
|
75
|
+
roe: f.roe, revenueYoY: f.revenueYoY, profitYoY: f.profitYoY, reportDate: f.reportDate,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { fetchFundamentals, scoreFundamentals };
|
package/http-util.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享的 HTTP JSON 拉取工具(带超时)
|
|
3
|
+
* 估值/基本面模块用它访问东方财富的 datacenter 接口。
|
|
4
|
+
*/
|
|
5
|
+
const https = require('https');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
|
|
8
|
+
const REQUEST_TIMEOUT = 8000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET 一个返回 JSON 的 URL,解析后 resolve。失败/超时 reject。
|
|
12
|
+
* 不处理重定向(东方财富 datacenter / push2 不重定向)。
|
|
13
|
+
*/
|
|
14
|
+
function getJSON(url, headers = {}) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const lib = url.startsWith('https') ? https : http;
|
|
17
|
+
const req = lib.get(url, { headers: { 'User-Agent': 'Mozilla/5.0', ...headers } }, (res) => {
|
|
18
|
+
const chunks = [];
|
|
19
|
+
res.on('data', c => chunks.push(c));
|
|
20
|
+
res.on('end', () => {
|
|
21
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8'))); }
|
|
22
|
+
catch (e) { reject(new Error('JSON解析失败: ' + e.message)); }
|
|
23
|
+
});
|
|
24
|
+
res.on('error', reject);
|
|
25
|
+
});
|
|
26
|
+
req.setTimeout(REQUEST_TIMEOUT, () => req.destroy(new Error('请求超时')));
|
|
27
|
+
req.on('error', reject);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 'sh600027' → '600027.SH';'sz000657' → '000657.SZ';非A股返回 null */
|
|
32
|
+
function toSecuCode(code) {
|
|
33
|
+
const m = /^(sh|sz)(\d{6})$/i.exec(code);
|
|
34
|
+
return m ? `${m[2]}.${m[1].toUpperCase()}` : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { getJSON, toSecuCode };
|