@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/server.js CHANGED
@@ -18,8 +18,21 @@ const {
18
18
  } = require('./analyze');
19
19
  const { computeScore } = require('./scoring');
20
20
  const { backtest: runBacktest } = require('./backtest');
21
+ const { fetchValuation, scoreValuation } = require('./valuation');
22
+ const { fetchFundamentals, scoreFundamentals } = require('./fundamentals');
23
+ const { fetchNewsCached, scoreNews } = require('./news');
21
24
 
22
25
  const PORT = 3000;
26
+ const HOST = '127.0.0.1';
27
+
28
+ // 搜索接口的单次请求超时(毫秒),避免上游卡住时请求永久挂起
29
+ const REQUEST_TIMEOUT = 8000;
30
+
31
+ function withTimeout(req, reject) {
32
+ req.setTimeout(REQUEST_TIMEOUT, () => req.destroy(new Error('请求超时')));
33
+ req.on('error', reject);
34
+ return req;
35
+ }
23
36
 
24
37
  // ==================== 股票搜索(中文/拼音) ====================
25
38
 
@@ -27,16 +40,16 @@ function searchStock(keyword) {
27
40
  return new Promise((resolve, reject) => {
28
41
  const encoded = encodeURIComponent(keyword);
29
42
  const url = `https://smartbox.gtimg.cn/s3/?q=${encoded}&t=all`;
30
- https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
43
+ withTimeout(https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
31
44
  if (res.statusCode === 301 || res.statusCode === 302) {
32
45
  const client = res.headers.location.startsWith('https') ? https : http;
33
- client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
46
+ withTimeout(client.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
34
47
  collectSearch(res2, resolve, reject);
35
- }).on('error', reject);
48
+ }), reject);
36
49
  return;
37
50
  }
38
51
  collectSearch(res, resolve, reject);
39
- }).on('error', reject);
52
+ }), reject);
40
53
  });
41
54
  }
42
55
 
@@ -74,6 +87,124 @@ async function resolveStockCode(input) {
74
87
  return results.length > 0 ? results[0].code : null;
75
88
  }
76
89
 
90
+ // ==================== 批量评分排名 ====================
91
+
92
+ /**
93
+ * 对一组股票跑同一套 computeScore,按综合评分排序。
94
+ * - 大盘环境只取一次,所有股票共享(与 CLI analyze.js 一致)
95
+ * - A 股实时行情用新浪批量接口一次拿全(支持多代码)
96
+ * - 历史 K 线分批并发拉取,控制并发避免被上游限流
97
+ * 返回结果只含评分摘要,不含完整指标明细。
98
+ */
99
+ async function rankStocks(inputs) {
100
+ const resolved = await Promise.all(inputs.map(i => resolveStockCode(i).catch(() => null)));
101
+ // 去重 + 去空
102
+ const codes = [...new Set(resolved.filter(Boolean))];
103
+
104
+ const marketEnv = await getMarketEnvironment();
105
+
106
+ // 大盘指数 K 线只拉一次,供所有标的的回测做大盘环境过滤(与实盘一致)
107
+ let indexKlines = null;
108
+ try {
109
+ indexKlines = await fetchHistory('sh000001', 500);
110
+ if (indexKlines.length < 30) indexKlines = null;
111
+ } catch (e) { /* 降级:无大盘过滤 */ }
112
+
113
+ // A 股实时行情批量拿(一个请求多代码)
114
+ const aShares = codes.filter(c => !c.startsWith('tw'));
115
+ const rtMap = {};
116
+ if (aShares.length) {
117
+ try {
118
+ const arr = await fetchRealtime(aShares);
119
+ for (const r of arr) rtMap[r.code] = r;
120
+ } catch (e) { /* 批量实时失败时,各股单独降级处理 */ }
121
+ }
122
+
123
+ async function analyzeOne(code) {
124
+ try {
125
+ const isTW = code.startsWith('tw');
126
+ let rt, klines;
127
+ // 拉 500 天:评分只看近端窗口,回测需要长历史,一次拉够两用
128
+ if (isTW) {
129
+ const [rtArr, kl] = await Promise.all([fetchTWRealtime(code), fetchTWHistory(code, 500)]);
130
+ rt = rtArr[0]; klines = kl;
131
+ } else {
132
+ rt = rtMap[code];
133
+ klines = await fetchHistory(code, 500);
134
+ if (!rt) { const a = await fetchRealtime([code]); rt = a[0]; }
135
+ }
136
+ if (!rt) return { code, name: code, error: '无实时数据' };
137
+ if (!klines || klines.length < 60) return { code, name: rt.name || code, error: `数据不足(${klines ? klines.length : 0}天)` };
138
+ const a = computeScore(klines, { marketEnv, realtime: rt });
139
+
140
+ // 估值 + 基本面 + 消息面(并行;失败不影响其余维度,降级为 null)
141
+ const [val, fund, news] = await Promise.all([
142
+ fetchValuation(code).then(scoreValuation).catch(() => ({ score: null, signals: ['估值接口失败'] })),
143
+ fetchFundamentals(code).then(scoreFundamentals).catch(() => ({ score: null, signals: ['财务接口失败'] })),
144
+ fetchNewsCached(code).then(scoreNews).catch(() => ({ score: null, signals: ['消息接口失败'] })),
145
+ ]);
146
+
147
+ // 回测(数据足够时):用与单股一致的默认参数,拿净收益指标
148
+ let bt = null;
149
+ if (klines.length >= 80) {
150
+ const r = runBacktest(klines, { startIdx: 60, maxHoldDays: 30, indexKlines, trailing: false, useTP1: true });
151
+ if (r.long) {
152
+ bt = {
153
+ trades: r.long.total,
154
+ winRate: r.long.winRate,
155
+ compoundReturn: r.long.compoundReturn,
156
+ maxDrawdown: r.long.maxDrawdown,
157
+ profitFactor: r.long.profitFactor,
158
+ };
159
+ }
160
+ }
161
+
162
+ // ===== 综合分(均衡型:技术/估值/基本面/消息面 等权,缺失维度按现有维度平均) =====
163
+ const techDim = a.summary.normalizedScore; // 0~100
164
+ const dims = { technical: techDim, valuation: val.score, fundamental: fund.score, news: news.score };
165
+ const present = [techDim, val.score, fund.score, news.score].filter(s => s != null);
166
+ const composite = present.length ? Math.round(present.reduce((x, y) => x + y, 0) / present.length) : techDim;
167
+
168
+ return {
169
+ code, name: rt.name || code,
170
+ price: rt.price, changePct: rt.changePct,
171
+ totalScore: a.summary.totalScore,
172
+ normalizedScore: techDim,
173
+ signal: a.summary.signal,
174
+ marketState: a.marketState,
175
+ riskRewardRatio: a.riskReward.riskRewardRatio,
176
+ positionAdvice: a.riskReward.positionAdvice,
177
+ backtest: bt,
178
+ composite,
179
+ dims,
180
+ valuation: { score: val.score, label: val.label || null, peTTM: val.peTTM != null ? +val.peTTM.toFixed(1) : null, pePercentile: val.pePercentile, board: val.board || null, signals: val.signals },
181
+ fundamental: { score: fund.score, label: fund.label || null, roe: fund.roe, revenueYoY: fund.revenueYoY, profitYoY: fund.profitYoY, reportDate: fund.reportDate, signals: fund.signals },
182
+ news: { score: news.score, label: news.label || null, hitCount: news.hitCount || 0, signals: news.signals },
183
+ };
184
+ } catch (e) {
185
+ return { code, name: code, error: e.message };
186
+ }
187
+ }
188
+
189
+ // 限并发(每批 5 只)
190
+ const CONCURRENCY = 5;
191
+ const results = [];
192
+ for (let i = 0; i < codes.length; i += CONCURRENCY) {
193
+ const batch = codes.slice(i, i + CONCURRENCY);
194
+ results.push(...await Promise.all(batch.map(analyzeOne)));
195
+ }
196
+
197
+ // 排序:可分析的按三维综合分降序,出错/数据不足的排末尾
198
+ results.sort((x, y) => {
199
+ if (x.error && y.error) return 0;
200
+ if (x.error) return 1;
201
+ if (y.error) return -1;
202
+ return y.composite - x.composite;
203
+ });
204
+
205
+ return { marketEnv, count: results.length, results };
206
+ }
207
+
77
208
  // ==================== HTTP 服务 ====================
78
209
 
79
210
  const server = http.createServer(async (req, res) => {
@@ -161,6 +292,7 @@ const server = http.createServer(async (req, res) => {
161
292
  dataRange: btResult.dataRange,
162
293
  long: btResult.long,
163
294
  short: btResult.short,
295
+ costs: btResult.costs,
164
296
  trades: btResult.trades.slice(-20), // 最近20笔
165
297
  grade,
166
298
  }));
@@ -171,6 +303,27 @@ const server = http.createServer(async (req, res) => {
171
303
  return;
172
304
  }
173
305
 
306
+ // API: 批量评分排名 — 对一组股票跑同一套 computeScore,按综合评分从高到低排序
307
+ // 注意:这是工具的技术面评分排名,不是投资建议
308
+ if (url.pathname === '/api/rank') {
309
+ const raw = url.searchParams.get('codes');
310
+ if (!raw) {
311
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
312
+ res.end(JSON.stringify({ error: '请提供股票代码列表(codes=逗号分隔)' }));
313
+ return;
314
+ }
315
+ try {
316
+ const inputs = raw.split(',').map(s => s.trim()).filter(Boolean);
317
+ const out = await rankStocks(inputs);
318
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
319
+ res.end(JSON.stringify(out));
320
+ } catch (e) {
321
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
322
+ res.end(JSON.stringify({ error: `排名出错: ${e.message}` }));
323
+ }
324
+ return;
325
+ }
326
+
174
327
  // API: 分析股票
175
328
  if (url.pathname === '/api/analyze') {
176
329
  const input = url.searchParams.get('code');
@@ -196,14 +349,29 @@ const server = http.createServer(async (req, res) => {
196
349
  res.end(JSON.stringify({ error: `未找到股票 ${code} 的实时数据` }));
197
350
  return;
198
351
  }
199
- if (klines.length < 30) {
352
+ if (klines.length < 60) {
200
353
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
201
- res.end(JSON.stringify({ error: `${code} 历史数据不足,无法进行有效分析` }));
354
+ res.end(JSON.stringify({ error: `${code} 历史数据不足60天(仅${klines.length}天),无法进行有效分析` }));
202
355
  return;
203
356
  }
204
357
 
205
358
  const marketEnv = await getMarketEnvironment();
206
359
  const analysis = computeScore(klines, { marketEnv, realtime: realtimeArr[0] });
360
+
361
+ // 估值 + 基本面 + 消息面(并行;失败降级为 null,不影响技术面输出)
362
+ const [val, fund, news] = await Promise.all([
363
+ fetchValuation(code).then(scoreValuation).catch(() => ({ score: null, signals: ['估值接口失败'] })),
364
+ fetchFundamentals(code).then(scoreFundamentals).catch(() => ({ score: null, signals: ['财务接口失败'] })),
365
+ fetchNewsCached(code).then(scoreNews).catch(() => ({ score: null, signals: ['消息接口失败'] })),
366
+ ]);
367
+ analysis.valuation = val;
368
+ analysis.fundamental = fund;
369
+ analysis.news = news;
370
+ const techDim = analysis.summary.normalizedScore;
371
+ const present = [techDim, val.score, fund.score, news.score].filter(s => s != null);
372
+ analysis.composite = present.length ? Math.round(present.reduce((x, y) => x + y, 0) / present.length) : techDim;
373
+ analysis.dims = { technical: techDim, valuation: val.score, fundamental: fund.score, news: news.score };
374
+
207
375
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
208
376
  res.end(JSON.stringify(analysis));
209
377
  } catch (e) {
@@ -235,12 +403,12 @@ function openBrowser(url) {
235
403
  }
236
404
 
237
405
  function startServer(port) {
238
- server.listen(port, () => {
406
+ server.listen(port, HOST, () => {
239
407
  console.log(`\n${'='.repeat(50)}`);
240
408
  console.log(` A股/台股综合分析工具`);
241
- console.log(` 打开浏览器访问: http://127.0.0.1:${port}`);
409
+ console.log(` 打开浏览器访问: http://${HOST}:${port}`);
242
410
  console.log(`${'='.repeat(50)}\n`);
243
- openBrowser(`http://127.0.0.1:${port}`);
411
+ openBrowser(`http://${HOST}:${port}`);
244
412
  });
245
413
  server.on('error', (err) => {
246
414
  if (err.code === 'EADDRINUSE') {
@@ -260,4 +428,4 @@ if (require.main === module) {
260
428
  main();
261
429
  }
262
430
 
263
- module.exports = { server, startServer, searchStock, resolveStockCode, main };
431
+ module.exports = { server, startServer, searchStock, resolveStockCode, rankStocks, main };
package/valuation.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * 估值模块 — 数据来自东方财富 F10 估值分析(RPT_VALUEANALYSIS_DET)
3
+ *
4
+ * 拉近一年(约250个交易日)的 PE_TTM / PB 序列,既取当前值,也算历史分位。
5
+ * 打分思路:越便宜(分位越低)分越高;亏损(PE<=0)直接判为高估区。
6
+ *
7
+ * 注意:仅 A 股可用;估值/基本面均为"当前值",不参与回测(免费源无历史时点快照)。
8
+ */
9
+ const { getJSON, toSecuCode } = require('./http-util');
10
+
11
+ async function fetchValuation(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_VALUEANALYSIS_DET'
16
+ + '&columns=TRADE_DATE,PE_TTM,PB_MRQ,PS_TTM,PEG_CAR,BOARD_NAME'
17
+ + `&filter=(SECUCODE%3D%22${secu}%22)`
18
+ + '&pageSize=250&sortColumns=TRADE_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 cur = rows[0];
23
+
24
+ // 当前值在历史序列中的分位(0=最便宜,100=最贵);只用正值样本
25
+ const pct = (series, val) => {
26
+ if (val == null) return null;
27
+ const s = series.filter(x => x != null && x > 0);
28
+ if (!s.length) return null;
29
+ const below = s.filter(x => x <= val).length;
30
+ return Math.round(below / s.length * 100);
31
+ };
32
+ const peSeries = rows.map(r => r.PE_TTM);
33
+ const pbSeries = rows.map(r => r.PB_MRQ);
34
+
35
+ return {
36
+ peTTM: cur.PE_TTM, pb: cur.PB_MRQ, ps: cur.PS_TTM, peg: cur.PEG_CAR, board: cur.BOARD_NAME,
37
+ pePercentile: cur.PE_TTM > 0 ? pct(peSeries, cur.PE_TTM) : null,
38
+ pbPercentile: cur.PB_MRQ > 0 ? pct(pbSeries, cur.PB_MRQ) : null,
39
+ historyDays: rows.length,
40
+ };
41
+ }
42
+
43
+ /** 估值打分,返回 0~100(越高=越便宜) + 文字信号 */
44
+ function scoreValuation(v) {
45
+ if (!v) return { score: null, signals: ['无估值数据(非A股或接口失败)'] };
46
+ const signals = [];
47
+ let score = 50;
48
+
49
+ if (v.peTTM == null || v.peTTM <= 0) {
50
+ signals.push('PE(TTM)为负/缺失 → 当前处于亏损,估值参考意义有限');
51
+ score = 25;
52
+ } else if (v.pePercentile != null) {
53
+ score = 100 - v.pePercentile; // 越便宜分越高
54
+ const tag = v.pePercentile <= 30 ? '偏低(便宜)' : v.pePercentile >= 70 ? '偏高(贵)' : '中性';
55
+ signals.push(`PE(TTM)=${v.peTTM.toFixed(1)},近一年 ${v.pePercentile}% 分位(${tag})`);
56
+ } else {
57
+ signals.push(`PE(TTM)=${v.peTTM.toFixed(1)}(无历史分位)`);
58
+ }
59
+
60
+ if (v.pb != null && v.pb > 0) {
61
+ if (v.pbPercentile != null) {
62
+ score = score * 0.7 + (100 - v.pbPercentile) * 0.3; // PB 分位做次要修正
63
+ signals.push(`PB=${v.pb.toFixed(2)},近一年 ${v.pbPercentile}% 分位`);
64
+ } else {
65
+ signals.push(`PB=${v.pb.toFixed(2)}`);
66
+ }
67
+ }
68
+
69
+ if (v.peg != null && v.peg > 0) {
70
+ signals.push(`PEG=${v.peg.toFixed(2)}${v.peg < 1 ? '(<1,成长已覆盖估值)' : ''}`);
71
+ if (v.peg < 1) score = Math.min(100, score + 5);
72
+ }
73
+ if (v.board) signals.push(`行业:${v.board}`);
74
+
75
+ score = Math.max(0, Math.min(100, Math.round(score)));
76
+ return {
77
+ score, signals,
78
+ label: score >= 65 ? '低估' : score >= 40 ? '合理' : '高估',
79
+ peTTM: v.peTTM, pb: v.pb, pePercentile: v.pePercentile, board: v.board,
80
+ };
81
+ }
82
+
83
+ module.exports = { fetchValuation, scoreValuation };