@kamuira/stock-analyzer 1.3.2 → 1.3.4

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.
Files changed (3) hide show
  1. package/analyze.js +141 -72
  2. package/index.html +75 -1
  3. package/package.json +1 -1
package/analyze.js CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  const http = require('http');
11
11
  const https = require('https');
12
+ const tls = require('tls');
13
+ const { execFile } = require('child_process');
12
14
  const { SMA } = require('./indicators');
13
15
  const { computeScore } = require('./scoring');
14
16
 
@@ -16,6 +18,99 @@ const { computeScore } = require('./scoring');
16
18
  // 不加超时会让 Promise 永久挂起,前端一直转圈。
17
19
  const REQUEST_TIMEOUT = 8000;
18
20
 
21
+ /**
22
+ * 走代理的 https GET(用于台股 Yahoo/TWSE 这类境外源)。
23
+ *
24
+ * 背景:国内 IP 直连 Yahoo finance 接口会被地区屏蔽(返回 403 的 <html lang="zh">),
25
+ * 而 Node 的 https.get 默认 *不读* HTTPS_PROXY 环境变量,所以即便用户开了
26
+ * 代理(Clash 等)台股也拿不到数据。这里在检测到 https_proxy/HTTPS_PROXY 时,
27
+ * 用 HTTP CONNECT 建隧道再做 TLS,纯 Node、无依赖;未设代理则退化为普通直连。
28
+ *
29
+ * @returns {import('http').ClientRequest} 与 https.get 一致,可被 withTimeout 包裹
30
+ */
31
+ function proxiedGet(reqOptions, cb) {
32
+ const proxy = process.env.https_proxy || process.env.HTTPS_PROXY
33
+ || process.env.all_proxy || process.env.ALL_PROXY;
34
+ if (!proxy) return https.get(reqOptions, cb);
35
+
36
+ const pu = new URL(proxy);
37
+ const host = reqOptions.hostname || reqOptions.host;
38
+ const port = reqOptions.port || 443;
39
+ const auth = pu.username
40
+ ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(
41
+ decodeURIComponent(pu.username) + ':' + decodeURIComponent(pu.password)).toString('base64') }
42
+ : {};
43
+
44
+ const opts = Object.assign({}, reqOptions, {
45
+ agent: false,
46
+ createConnection(_o, done) {
47
+ const c = http.request({
48
+ host: pu.hostname, port: pu.port || 80, method: 'CONNECT',
49
+ path: `${host}:${port}`, headers: auth,
50
+ });
51
+ c.on('connect', (res, socket) => {
52
+ if (res.statusCode !== 200) { done(new Error(`代理 CONNECT 失败: ${res.statusCode}`)); return; }
53
+ const tlsSock = tls.connect({ socket, servername: host }, () => done(null, tlsSock));
54
+ tlsSock.on('error', done);
55
+ });
56
+ c.on('error', done);
57
+ c.setTimeout(REQUEST_TIMEOUT, () => c.destroy(new Error('代理连接超时')));
58
+ c.end();
59
+ },
60
+ });
61
+ return https.get(opts, cb);
62
+ }
63
+
64
+ /** proxiedGet 的 Promise 版,拿整段响应文本(供台股源的回退路径用) */
65
+ function proxiedText(url) {
66
+ return new Promise((resolve, reject) => {
67
+ const u = new URL(url);
68
+ withTimeout(proxiedGet({
69
+ hostname: u.hostname, path: u.pathname + u.search,
70
+ headers: { 'User-Agent': 'Mozilla/5.0', Accept: '*/*' },
71
+ }, (res) => {
72
+ const chunks = [];
73
+ res.on('data', c => chunks.push(c));
74
+ res.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
75
+ }), reject);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * 用系统 curl 取文本。台股的 Yahoo / TWSE 会按 TLS 指纹(JA3)歧视 Node 的握手
81
+ * (Node 直连/隧道都被 403 或挂起),而 curl 的指纹能通过;curl 还会自动读取
82
+ * https_proxy 环境变量,正好解决国内访问境外源需走代理的问题。
83
+ * curl 不存在时(罕见)抛 ENOENT,由 twFetch 回退到 Node 路径。
84
+ */
85
+ function curlText(url) {
86
+ return new Promise((resolve, reject) => {
87
+ // 经代理访问境外源首连常慢/偶发 SSL 重置(exit 35),故给足超时并自动重试。
88
+ execFile('curl', ['-s', '--compressed', '--connect-timeout', '8', '--max-time', '20',
89
+ '--retry', '3', '--retry-delay', '1', '--retry-all-errors',
90
+ '-A', 'Mozilla/5.0', '-H', 'Accept: */*', url],
91
+ { maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
92
+ if (err) { reject(err); return; }
93
+ if (!stdout) { reject(new Error('curl 返回空响应')); return; }
94
+ resolve(stdout);
95
+ });
96
+ });
97
+ }
98
+
99
+ /** 台股取数:优先 curl(过指纹+走代理),无 curl 时回退 Node 代理隧道 */
100
+ async function twFetch(url) {
101
+ let lastErr;
102
+ for (let attempt = 0; attempt < 3; attempt++) {
103
+ if (attempt > 0) await new Promise(r => setTimeout(r, 600));
104
+ try {
105
+ return await curlText(url);
106
+ } catch (e) {
107
+ if (e && e.code === 'ENOENT') return proxiedText(url); // 环境无 curl,回退 Node 隧道
108
+ lastErr = e; // 多为经代理首连的偶发 SSL 重置,稍后重试
109
+ }
110
+ }
111
+ throw lastErr;
112
+ }
113
+
19
114
  /**
20
115
  * 给一个 http(s) 请求挂上超时和错误处理。
21
116
  * @param {import('http').ClientRequest} req http(s).get 返回的请求对象
@@ -144,82 +239,56 @@ function collect(res, code, resolve, reject) {
144
239
 
145
240
  // ==================== 台股数据 ====================
146
241
 
147
- function fetchTWRealtime(code) {
148
- return new Promise((resolve, reject) => {
149
- const num = code.replace(/^tw/i, '');
150
- const exCh = `tse_${num}.tw|otc_${num}.tw`;
151
- const url = `/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
152
- withTimeout(https.get({ hostname: 'mis.twse.com.tw', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
153
- const chunks = [];
154
- res.on('data', c => chunks.push(c));
155
- res.on('end', () => {
156
- try {
157
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
158
- if (!json.msgArray || json.msgArray.length === 0) { resolve([]); return; }
159
- const results = [];
160
- for (const item of json.msgArray) {
161
- if (!item.z || item.z === '-') continue;
162
- const price = parseFloat(item.z);
163
- const yesterdayClose = parseFloat(item.y);
164
- results.push({
165
- code: `tw${item.c}`, name: item.n, price,
166
- open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
167
- low: parseFloat(item.l) || price, yesterdayClose,
168
- change: +(price - yesterdayClose).toFixed(2),
169
- changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
170
- volume: Math.round(parseInt(item.v) || 0), amount: 0,
171
- time: `${item.d} ${item.t || ''}`,
172
- });
173
- }
174
- resolve(results);
175
- } catch (e) { reject(e); }
176
- });
177
- }), reject);
178
- });
242
+ async function fetchTWRealtime(code) {
243
+ const num = code.replace(/^tw/i, '');
244
+ const exCh = `tse_${num}.tw|otc_${num}.tw`;
245
+ const url = `https://mis.twse.com.tw/stock/api/getStockInfo.jsp?ex_ch=${exCh}&_=${Date.now()}`;
246
+ const json = JSON.parse(await twFetch(url));
247
+ if (!json.msgArray || json.msgArray.length === 0) return [];
248
+ const results = [];
249
+ for (const item of json.msgArray) {
250
+ if (!item.z || item.z === '-') continue;
251
+ const price = parseFloat(item.z);
252
+ const yesterdayClose = parseFloat(item.y);
253
+ results.push({
254
+ code: `tw${item.c}`, name: item.n, price,
255
+ open: parseFloat(item.o) || price, high: parseFloat(item.h) || price,
256
+ low: parseFloat(item.l) || price, yesterdayClose,
257
+ change: +(price - yesterdayClose).toFixed(2),
258
+ changePct: yesterdayClose > 0 ? +(((price - yesterdayClose) / yesterdayClose) * 100).toFixed(2) : 0,
259
+ volume: Math.round(parseInt(item.v) || 0), amount: 0,
260
+ time: `${item.d} ${item.t || ''}`,
261
+ });
262
+ }
263
+ return results;
179
264
  }
180
265
 
181
- function fetchTWHistory(code, days = 120) {
182
- return new Promise((resolve, reject) => {
183
- const num = code.replace(/^tw/i, '');
184
- const symbol = `${num}.TW`;
185
- const period2 = Math.floor(Date.now() / 1000);
186
- const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
187
- const url = `/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
188
- withTimeout(https.get({ hostname: 'query1.finance.yahoo.com', path: url, headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
189
- if (res.statusCode === 301 || res.statusCode === 302) {
190
- withTimeout(https.get(res.headers.location, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res2) => {
191
- collectTWHist(res2, resolve, reject);
192
- }), reject);
193
- return;
194
- }
195
- collectTWHist(res, resolve, reject);
196
- }), reject);
197
- });
266
+ async function fetchTWHistory(code, days = 120) {
267
+ const num = code.replace(/^tw/i, '');
268
+ const symbol = `${num}.TW`;
269
+ const period2 = Math.floor(Date.now() / 1000);
270
+ const period1 = period2 - Math.floor(days * 24 * 60 * 60 * 1.5);
271
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${symbol}?period1=${period1}&period2=${period2}&interval=1d`;
272
+ return parseTWHist(await twFetch(url));
198
273
  }
199
274
 
200
- function collectTWHist(res, resolve, reject) {
201
- const chunks = [];
202
- res.on('data', c => chunks.push(c));
203
- res.on('end', () => {
204
- try {
205
- const json = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
206
- const result = json.chart && json.chart.result && json.chart.result[0];
207
- if (!result || !result.timestamp) { resolve([]); return; }
208
- const ts = result.timestamp, q = result.indicators.quote[0];
209
- const klines = [];
210
- for (let i = 0; i < ts.length; i++) {
211
- if (q.close[i] === null) continue;
212
- const d = new Date(ts[i] * 1000);
213
- klines.push({
214
- date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
215
- open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
216
- high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
217
- volume: Math.round((q.volume[i]||0)/1000),
218
- });
219
- }
220
- resolve(klines);
221
- } catch (e) { reject(e); }
222
- });
275
+ function parseTWHist(body) {
276
+ const json = JSON.parse(body);
277
+ const result = json.chart && json.chart.result && json.chart.result[0];
278
+ if (!result || !result.timestamp) return [];
279
+ const ts = result.timestamp, q = result.indicators.quote[0];
280
+ const klines = [];
281
+ for (let i = 0; i < ts.length; i++) {
282
+ if (q.close[i] === null) continue;
283
+ const d = new Date(ts[i] * 1000);
284
+ klines.push({
285
+ date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
286
+ open: +(q.open[i]||0).toFixed(2), close: +(q.close[i]||0).toFixed(2),
287
+ high: +(q.high[i]||0).toFixed(2), low: +(q.low[i]||0).toFixed(2),
288
+ volume: Math.round((q.volume[i]||0)/1000),
289
+ });
290
+ }
291
+ return klines;
223
292
  }
224
293
 
225
294
  // ==================== 大盘环境 ====================
package/index.html CHANGED
@@ -249,6 +249,47 @@
249
249
  <button onclick="quickAnalyze('sz002340')">格林美</button>
250
250
  <button onclick="quickAnalyze('sh600900')">长江电力</button>
251
251
  <button onclick="quickAnalyze('sz002916')">深南电路</button>
252
+ <button onclick="quickAnalyze('sz002378')">章源钨业</button>
253
+ <button onclick="quickAnalyze('sh600699')">均胜电子</button>
254
+ <button onclick="quickAnalyze('sz002709')">天赐材料</button>
255
+ <button onclick="quickAnalyze('sz000338')">潍柴动力</button>
256
+ <button onclick="quickAnalyze('sz002125')">湘潭电化</button>
257
+ <button onclick="quickAnalyze('sh600089')">特变电工</button>
258
+ <button onclick="quickAnalyze('sh603124')">江南新材</button>
259
+ <button onclick="quickAnalyze('sz000983')">山西焦煤</button>
260
+ <button onclick="quickAnalyze('sz001314')">亿道信息</button>
261
+ <button onclick="quickAnalyze('sh600158')">中体产业</button>
262
+ <button onclick="quickAnalyze('sz000534')">万泽股份</button>
263
+ <button onclick="quickAnalyze('sz002747')">埃斯顿</button>
264
+ <button onclick="quickAnalyze('sz002050')">三花智控</button>
265
+ <button onclick="quickAnalyze('sz002167')">东方锆业</button>
266
+ <button onclick="quickAnalyze('sz002931')">锋龙股份</button>
267
+ <button onclick="quickAnalyze('sh600458')">时代新材</button>
268
+ <button onclick="quickAnalyze('sh603500')">祥和实业</button>
269
+ <button onclick="quickAnalyze('sz000021')">深科技</button>
270
+ <button onclick="quickAnalyze('sz002028')">思源电气</button>
271
+ <button onclick="quickAnalyze('sh600563')">法拉电子</button>
272
+ <button onclick="quickAnalyze('sz000725')">京东方A</button>
273
+ <button onclick="quickAnalyze('sh605358')">立昂微</button>
274
+ <button onclick="quickAnalyze('sz002938')">鹏鼎控股</button>
275
+ <button onclick="quickAnalyze('sh600522')">中天科技</button>
276
+ <button onclick="quickAnalyze('sz002213')">大为股份</button>
277
+ <button onclick="quickAnalyze('sh600226')">亨通股份</button>
278
+ <button onclick="quickAnalyze('sz001389')">广合科技</button>
279
+ <button onclick="quickAnalyze('sz002179')">中航光电</button>
280
+ <button onclick="quickAnalyze('sz002491')">通鼎互联</button>
281
+ <button onclick="quickAnalyze('sh601231')">环旭电子</button>
282
+ <button onclick="quickAnalyze('sz002156')">通富微电</button>
283
+ <button onclick="quickAnalyze('sz300476')">胜宏科技</button>
284
+ <button onclick="quickAnalyze('sh600406')">国电南瑞</button>
285
+ <button onclick="quickAnalyze('sz000807')">云铝股份</button>
286
+ <button onclick="quickAnalyze('sh600961')">株冶集团</button>
287
+ <button onclick="quickAnalyze('sh605117')">德业股份</button>
288
+ <button onclick="quickAnalyze('sh603271')">永杰新材</button>
289
+ <button onclick="quickAnalyze('sh603129')">春风动力</button>
290
+ <button onclick="quickAnalyze('sh603288')">海天味业</button>
291
+ <button onclick="quickAnalyze('sh603277')">银都股份</button>
292
+ <button onclick="quickAnalyze('sh603606')">东方电缆</button>
252
293
  </div>
253
294
  <div class="shortcuts" id="customShortcuts"></div>
254
295
 
@@ -323,7 +364,13 @@
323
364
  'sh600206','sh600111','sh601318','sh601066',
324
365
  'sz000510','sh601021','sz000657','sh600027','sh603659',
325
366
  'sh600309','sz300170','sh605589','sz300308','sz002463','sh603259','sz002371',
326
- 'sz300502','sh600487','sh601869','sh600869','sz002340','sh600900','sz002916'
367
+ 'sz300502','sh600487','sh601869','sh600869','sz002340','sh600900','sz002916',
368
+ 'sz002378','sh600699','sz002709','sz000338','sz002125','sh600089','sh603124',
369
+ 'sz000983','sz001314','sh600158','sz000534','sz002747','sz002050','sz002167',
370
+ 'sz002931','sh600458','sh603500','sz000021','sz002028','sh600563','sz000725',
371
+ 'sh605358','sz002938','sh600522','sz002213','sh600226','sz001389','sz002179',
372
+ 'sz002491','sh601231','sz002156','sz300476','sh600406','sz000807','sh600961',
373
+ 'sh605117','sh603271','sh603129','sh603288','sh603277','sh603606'
327
374
  ];
328
375
 
329
376
  const rankBtn = document.getElementById('rankBtn');
@@ -358,6 +405,14 @@
358
405
 
359
406
  // 保存上一次排名的渲染结果(仅前端内存,非接口缓存),用于点进个股后快速返回
360
407
  let lastRankHTML = '';
408
+ let lastRankData = null; // 上次排名的原始数据,用于本地重新筛选(不重算)
409
+ let rankFilterStrict = false; // 是否只看"可博弈"(综合评分≥5 且 盈亏比>1)
410
+
411
+ function toggleRankFilter() {
412
+ if (!lastRankData) return;
413
+ rankFilterStrict = !rankFilterStrict;
414
+ renderRank(lastRankData); // 用内存数据重渲染,零网络、零重算
415
+ }
361
416
 
362
417
  function backToRank() {
363
418
  if (!lastRankHTML) return;
@@ -385,6 +440,7 @@
385
440
  const res = await fetch(`/api/rank?codes=${encodeURIComponent(list.join(','))}`);
386
441
  const data = await res.json();
387
442
  if (data.error) { showError(data.error); return; }
443
+ lastRankData = data;
388
444
  renderRank(data);
389
445
  } catch (e) {
390
446
  showError('网络请求失败: ' + e.message);
@@ -471,6 +527,16 @@
471
527
  }
472
528
  html += `<div style="font-size:11px;color:#ff8a80;margin-top:8px">注:这是按规则筛出的"候选",仍不含消息面;买前自行查公告、定仓位、设止损。</div></div>`;
473
529
 
530
+ // 🎯 可博弈筛选:技术综合评分≥5 且 盈亏比>1(用户自定义条件)
531
+ const passStrict = r => r.totalScore >= 5 && r.riskRewardRatio != null && r.riskRewardRatio > 1;
532
+ const strictCount = ok.filter(passStrict).length;
533
+ html += `<div style="margin-bottom:12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
534
+ <button onclick="toggleRankFilter()" style="padding:8px 16px;border:1px solid ${rankFilterStrict ? '#4caf50' : '#5a3dbf'};border-radius:8px;background:${rankFilterStrict ? '#4caf5022' : 'transparent'};color:${rankFilterStrict ? '#4caf50' : '#b39dff'};font-size:13px;cursor:pointer">
535
+ ${rankFilterStrict ? '✓ 已筛选:可博弈(评分≥5 且 盈亏比>1) · 点此显示全部' : '🎯 只看可博弈(技术评分≥5 且 盈亏比>1)'}
536
+ </button>
537
+ <span style="font-size:12px;color:#888">符合条件 <b style="color:${strictCount ? '#4caf50' : '#888'}">${strictCount}</b> 只</span>
538
+ </div>`;
539
+
474
540
  html += `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">
475
541
  <thead><tr style="color:#888;text-align:left;border-bottom:1px solid #2a3a4a">
476
542
  <th style="padding:10px 6px">#</th>
@@ -483,10 +549,14 @@
483
549
  <th style="padding:10px 6px;text-align:center" title="ROE/营收利润增速/负债,越优质越高">基本面</th>
484
550
  <th style="padding:10px 6px;text-align:center" title="公告关键词:利好+/利空−,50为中性">消息</th>
485
551
  <th style="padding:10px 6px">技术信号</th>
552
+ <th style="padding:10px 6px;text-align:center" title="风险收益比:止盈空间/止损空间,>1 才划算">盈亏比</th>
486
553
  <th style="padding:10px 6px;text-align:right" title="回测满仓复利净收益(扣费)">复利回测</th>
487
554
  </tr></thead><tbody>`;
488
555
 
556
+ let shown = 0;
489
557
  ok.forEach((r, i) => {
558
+ if (rankFilterStrict && !passStrict(r)) return; // 筛选模式:跳过不达标的,保留原排名号
559
+ shown++;
490
560
  const dir = r.changePct > 0 ? 'up' : r.changePct < 0 ? 'down' : 'flat';
491
561
  const sc = signalColor(r.signal, r.totalScore);
492
562
  const b = r.backtest;
@@ -509,9 +579,13 @@
509
579
  ${dimCell(f.score, f.label, fSub)}
510
580
  ${dimCell(r.news ? r.news.score : null, r.news ? r.news.label : null, r.news && r.news.hitCount ? r.news.hitCount + '条' : '')}
511
581
  <td style="padding:8px 6px"><span style="padding:2px 8px;border-radius:10px;font-size:12px;background:${sc}22;color:${sc}">${r.signal}</span></td>
582
+ <td style="padding:8px 6px;text-align:center;font-weight:bold;color:${rrColor(r.riskRewardRatio)}" title="技术综合评分 ${r.totalScore}">${r.riskRewardRatio != null ? r.riskRewardRatio : '-'}</td>
512
583
  ${btCell}
513
584
  </tr>`;
514
585
  });
586
+ if (rankFilterStrict && shown === 0) {
587
+ html += `<tr><td colspan="12" style="padding:20px;text-align:center;color:#ff9800;font-size:13px">没有股票同时满足 技术综合评分≥5 且 盈亏比>1 —— 当前多数标的要么动能不足、要么上方空间不够,空仓等待更稳妥。</td></tr>`;
588
+ }
515
589
  html += `</tbody></table></div>`;
516
590
 
517
591
  if (bad.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kamuira/stock-analyzer",
3
- "version": "1.3.2",
3
+ "version": "1.3.4",
4
4
  "preferGlobal": true,
5
5
  "description": "A股/台股综合分析工具 - 技术面+估值+基本面+消息面四维评分、批量排名、含成本回测、买卖建议",
6
6
  "main": "server.js",