@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 +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 +292 -6
- package/news.js +109 -0
- package/package.json +10 -3
- package/server.js +178 -10
- package/valuation.js +83 -0
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
|
-
})
|
|
48
|
+
}), reject);
|
|
36
49
|
return;
|
|
37
50
|
}
|
|
38
51
|
collectSearch(res, resolve, reject);
|
|
39
|
-
})
|
|
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 <
|
|
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
|
|
409
|
+
console.log(` 打开浏览器访问: http://${HOST}:${port}`);
|
|
242
410
|
console.log(`${'='.repeat(50)}\n`);
|
|
243
|
-
openBrowser(`http
|
|
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 };
|