@kamuira/stock-analyzer 1.2.6 → 1.3.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/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`,端口被占用会自动 +1 重试。
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 # 评分逻辑(唯一来源,被 analyze/backtest/server 共享)
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
- }).on('error', reject);
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
- }).on('error', reject);
117
+ }), reject);
97
118
  return;
98
119
  }
99
120
  collect(res, code, resolve, reject);
100
- }).on('error', reject);
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
- }).on('error', reject);
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
- }).on('error', reject);
192
+ }), reject);
172
193
  return;
173
194
  }
174
195
  collectTWHist(res, resolve, reject);
175
- }).on('error', reject);
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 returnPct = position.type === 'long'
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: +returnPct.toFixed(2),
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 returnPct = position.type === 'long'
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: +returnPct.toFixed(2),
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
- return summarize(trades, klines);
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.returnPct > 0);
214
- const losses = group.filter(t => t.returnPct <= 0);
215
- const returns = group.map(t => t.returnPct);
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 ? +(wins.reduce((a, t) => a + t.returnPct, 0) / wins.length).toFixed(2) : 0,
233
- avgLoss: losses.length > 0 ? +(losses.reduce((a, t) => a + t.returnPct, 0) / losses.length).toFixed(2) : 0,
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}% 累计收益: ${s.totalReturn}%`);
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 r = t.returnPct >= 0 ? `+${t.returnPct}%` : `${t.returnPct}%`;
290
- lines.push(` ${t.entryDate} ${sign} @${t.entryPrice} ${t.exitDate} @${t.exitPrice} | ${r} | ${t.holdDays}天 | ${reasonLabel[t.exitReason] || t.exitReason}`);
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
- lines.push(` 局限: 未计手续费和滑点(按用户要求)`);
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
- if (modeDesc.length === 0) modeDesc.push('默认: TP1止盈,无跟踪');
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('');
@@ -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 };