@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/index.html
CHANGED
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
<div class="search-box">
|
|
205
205
|
<input type="text" id="codeInput" placeholder="A股: 名称/代码/拼音 | 台股: 4位代码如2330" autocomplete="off">
|
|
206
206
|
<button id="analyzeBtn" onclick="doAnalyze()">分析</button>
|
|
207
|
+
<button id="rankBtn" onclick="doRank()" style="background:#5a3dbf">📊 批量评分排名</button>
|
|
207
208
|
</div>
|
|
208
209
|
|
|
209
210
|
<div class="shortcuts">
|
|
@@ -229,9 +230,24 @@
|
|
|
229
230
|
<button onclick="quickAnalyze('sh600111')">北方稀土</button>
|
|
230
231
|
<button onclick="quickAnalyze('sh601318')">中国平安</button>
|
|
231
232
|
<button onclick="quickAnalyze('sh601066')">中信建投</button>
|
|
233
|
+
<button onclick="quickAnalyze('sz000510')">新金路</button>
|
|
234
|
+
<button onclick="quickAnalyze('sh601021')">春秋航空</button>
|
|
235
|
+
<button onclick="quickAnalyze('sz000657')">中钨高新</button>
|
|
236
|
+
<button onclick="quickAnalyze('sh600027')">华电国际</button>
|
|
237
|
+
<button onclick="quickAnalyze('sh603659')">璞泰来</button>
|
|
232
238
|
</div>
|
|
233
239
|
<div class="shortcuts" id="customShortcuts"></div>
|
|
234
240
|
|
|
241
|
+
<div id="rankPanel" style="max-width:680px;margin:0 auto 24px;background:#141e28;border:1px solid #1f2d3a;border-radius:10px;padding:14px 16px">
|
|
242
|
+
<div style="font-size:13px;color:#b39dff;margin-bottom:8px;font-weight:bold">📊 排名股票池(默认清单已自动包含,在此填要额外加入的)</div>
|
|
243
|
+
<textarea id="rankListInput" rows="2" placeholder="额外加入的代码或名称,逗号/空格/换行分隔。例: 中钨高新, sz000510, 2330。留空则只排默认清单" style="width:100%;padding:10px 12px;border:1px solid #2a3a4a;border-radius:8px;background:#1a2a3a;color:#fff;font-size:13px;outline:none;resize:vertical;font-family:inherit"></textarea>
|
|
244
|
+
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap">
|
|
245
|
+
<button onclick="doRank()" style="padding:8px 18px;border:none;border-radius:8px;background:#5a3dbf;color:#fff;font-size:13px;cursor:pointer">保存并排名</button>
|
|
246
|
+
<button onclick="resetRankList()" style="padding:8px 14px;border:1px solid #2a3a4a;border-radius:8px;background:transparent;color:#aaa;font-size:12px;cursor:pointer">清空额外</button>
|
|
247
|
+
<span id="rankPoolHint" style="font-size:12px;color:#666"></span>
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
235
251
|
<div class="loading" id="loading">
|
|
236
252
|
<div class="spinner"></div>
|
|
237
253
|
<p id="loadingText">正在获取数据并分析中...</p>
|
|
@@ -285,6 +301,219 @@
|
|
|
285
301
|
doAnalyze(false); // 所有快捷按钮都显示回测
|
|
286
302
|
}
|
|
287
303
|
|
|
304
|
+
// 批量排名默认股票池:内置自选股 + 用户新增的 5 只(德明利已在列)
|
|
305
|
+
const DEFAULT_RANK_LIST = [
|
|
306
|
+
'sz002049','sh603893','sz300750','sz300274','sh603698','sh601138',
|
|
307
|
+
'sh600011','sh601600','sz002138','sh603986','sz002716','sh603256',
|
|
308
|
+
'sz001309','sh601899','sz000426','sz002428','sh600259','sh600362',
|
|
309
|
+
'sh600206','sh600111','sh601318','sh601066',
|
|
310
|
+
'sz000510','sh601021','sz000657','sh600027','sh603659'
|
|
311
|
+
];
|
|
312
|
+
|
|
313
|
+
const rankBtn = document.getElementById('rankBtn');
|
|
314
|
+
const rankListInput = document.getElementById('rankListInput');
|
|
315
|
+
const rankPoolHint = document.getElementById('rankPoolHint');
|
|
316
|
+
|
|
317
|
+
// 把输入框文本拆成代码/名称数组(逗号、空格、换行、中文逗号/顿号都当分隔符)
|
|
318
|
+
function parseRankInput(text) {
|
|
319
|
+
return (text || '').split(/[\s,,、;;]+/).map(s => s.trim()).filter(Boolean);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 初始化:有自定义就回填,否则留空(走默认)
|
|
323
|
+
function loadRankList() {
|
|
324
|
+
const saved = localStorage.getItem('rankListCustom');
|
|
325
|
+
if (saved) rankListInput.value = parseRankInput(saved).join(', ');
|
|
326
|
+
updateRankHint();
|
|
327
|
+
}
|
|
328
|
+
function updateRankHint() {
|
|
329
|
+
const extra = parseRankInput(rankListInput.value);
|
|
330
|
+
const total = DEFAULT_RANK_LIST.length + extra.length; // 实际去重后可能略少
|
|
331
|
+
rankPoolHint.textContent = extra.length
|
|
332
|
+
? `默认 ${DEFAULT_RANK_LIST.length} 只 + 额外 ${extra.length} 只 ≈ ${total} 只(含回测,约需 ${Math.max(5, Math.round(total * 0.4))}-${total * 2} 秒)`
|
|
333
|
+
: `仅默认 ${DEFAULT_RANK_LIST.length} 只(含回测,约需 ${Math.max(5, Math.round(DEFAULT_RANK_LIST.length * 0.4))}-${DEFAULT_RANK_LIST.length * 2} 秒)`;
|
|
334
|
+
}
|
|
335
|
+
function resetRankList() {
|
|
336
|
+
rankListInput.value = '';
|
|
337
|
+
localStorage.removeItem('rankListCustom');
|
|
338
|
+
updateRankHint();
|
|
339
|
+
}
|
|
340
|
+
rankListInput.addEventListener('input', updateRankHint);
|
|
341
|
+
loadRankList();
|
|
342
|
+
|
|
343
|
+
// 保存上一次排名的渲染结果(仅前端内存,非接口缓存),用于点进个股后快速返回
|
|
344
|
+
let lastRankHTML = '';
|
|
345
|
+
|
|
346
|
+
function backToRank() {
|
|
347
|
+
if (!lastRankHTML) return;
|
|
348
|
+
errorDiv.classList.remove('active');
|
|
349
|
+
resultDiv.innerHTML = lastRankHTML;
|
|
350
|
+
resultDiv.classList.add('active');
|
|
351
|
+
window.scrollTo(0, 0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function doRank() {
|
|
355
|
+
const extra = parseRankInput(rankListInput.value);
|
|
356
|
+
// 默认清单始终包含;额外的拼在后面(服务端按解析后的代码去重)
|
|
357
|
+
const list = [...DEFAULT_RANK_LIST, ...extra];
|
|
358
|
+
// 额外清单存本机(留空则清除)
|
|
359
|
+
if (extra.length) localStorage.setItem('rankListCustom', extra.join(','));
|
|
360
|
+
else localStorage.removeItem('rankListCustom');
|
|
361
|
+
|
|
362
|
+
btn.disabled = true;
|
|
363
|
+
rankBtn.disabled = true;
|
|
364
|
+
errorDiv.classList.remove('active');
|
|
365
|
+
resultDiv.classList.remove('active');
|
|
366
|
+
loading.classList.add('active');
|
|
367
|
+
loadingText.textContent = `正在批量评分 + 回测 ${list.length} 只股票(含历史回测,约需 ${Math.max(5, Math.round(list.length * 0.4))}-${list.length * 2} 秒)...`;
|
|
368
|
+
try {
|
|
369
|
+
const res = await fetch(`/api/rank?codes=${encodeURIComponent(list.join(','))}`);
|
|
370
|
+
const data = await res.json();
|
|
371
|
+
if (data.error) { showError(data.error); return; }
|
|
372
|
+
renderRank(data);
|
|
373
|
+
} catch (e) {
|
|
374
|
+
showError('网络请求失败: ' + e.message);
|
|
375
|
+
} finally {
|
|
376
|
+
btn.disabled = false;
|
|
377
|
+
rankBtn.disabled = false;
|
|
378
|
+
loading.classList.remove('active');
|
|
379
|
+
loadingText.textContent = '正在获取数据并分析中...';
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderRank(data) {
|
|
384
|
+
const { results, marketEnv } = data;
|
|
385
|
+
const ok = results.filter(r => !r.error);
|
|
386
|
+
const bad = results.filter(r => r.error);
|
|
387
|
+
|
|
388
|
+
// 综合筛选:回测攻守兼备(盈利因子≥1.5 且 最大回撤≤25% 且 复利>0)
|
|
389
|
+
const isQualified = b => b && b.profitFactor >= 1.5 && b.maxDrawdown <= 25 && b.compoundReturn > 0;
|
|
390
|
+
const starCount = ok.filter(r => isQualified(r.backtest)).length;
|
|
391
|
+
|
|
392
|
+
const signalColor = (s, score) =>
|
|
393
|
+
score >= 10 ? '#ff4444' : score >= 5 ? '#ff7043' : score >= 1 ? '#ff9800'
|
|
394
|
+
: score >= -4 ? '#888' : score >= -9 ? '#26c281' : '#00cc66';
|
|
395
|
+
const rrColor = rr => rr >= 2 ? '#4caf50' : rr >= 1 ? '#ff9800' : '#f44336';
|
|
396
|
+
|
|
397
|
+
let html = `<div style="background:#1a2530;border:1px solid #2a3a4a;border-radius:10px;padding:18px 20px;margin-bottom:18px">
|
|
398
|
+
<div style="font-size:17px;font-weight:bold;color:#b39dff;margin-bottom:6px">四维综合排名(均衡型)</div>
|
|
399
|
+
<div style="font-size:12px;color:#888;line-height:1.7">
|
|
400
|
+
综合分 = <b style="color:#aaa">技术面 / 估值 / 基本面 / 消息面</b> 四维等权平均(各 0~100,越高越好),按综合分从高到低排序,共 ${ok.length} 只有效${bad.length ? `(${bad.length} 只数据不足/失败)` : ''}。
|
|
401
|
+
大盘环境:${(marketEnv && marketEnv.signals ? marketEnv.signals.join(' | ') : '-')}<br>
|
|
402
|
+
· <b style="color:#aaa">技术</b>=评分标准化(择时) · <b style="color:#aaa">估值</b>=PE/PB历史分位 · <b style="color:#aaa">基本面</b>=ROE/增速/负债 · <b style="color:#aaa">消息</b>=公告关键词(利好+/利空−)<br>
|
|
403
|
+
<span style="color:#ffd54f">⭐ = 回测攻守兼备(盈利因子≥1.5 且 最大回撤≤25% 且 复利>0),本次 ${starCount} 只,已高亮。</span><br>
|
|
404
|
+
<span style="color:#ff8a80">估值/基本面/消息面均为当前值、不参与回测;消息面是公告关键词初筛(较粗);综合分高仅代表"现在更值得细看",不保证未来 ≠ 推荐买入。请自行判断。</span>
|
|
405
|
+
</div>
|
|
406
|
+
</div>`;
|
|
407
|
+
|
|
408
|
+
const cmpColor = c => c == null ? '#666' : c > 0 ? '#4caf50' : '#f44336';
|
|
409
|
+
const dimColor = s => s == null ? '#666' : s >= 65 ? '#4caf50' : s >= 45 ? '#ff9800' : '#f44336';
|
|
410
|
+
const dimCell = (score, label, sub) => score == null
|
|
411
|
+
? `<td style="padding:8px 6px;text-align:center;color:#555">—</td>`
|
|
412
|
+
: `<td style="padding:8px 6px;text-align:center"><div style="font-size:15px;font-weight:bold;color:${dimColor(score)}">${score}</div><div style="font-size:10px;color:#888">${label || ''}${sub ? ' ' + sub : ''}</div></td>`;
|
|
413
|
+
|
|
414
|
+
// ✅ 买入候选:同时满足全部硬性条件,再按综合分取前三
|
|
415
|
+
const isBuyCandidate = r => {
|
|
416
|
+
const f = r.fundamental || {}, v = r.valuation || {}, nw = r.news || {};
|
|
417
|
+
return r.composite >= 55
|
|
418
|
+
&& f.score != null && f.score >= 45 // 基本面不偏弱
|
|
419
|
+
&& v.score != null && v.score >= 40 // 估值不高估
|
|
420
|
+
&& (nw.score == null || nw.score >= 40) // 消息面无明显利空
|
|
421
|
+
&& r.totalScore >= 1 // 当前技术面=买入侧(谨慎买入及以上)
|
|
422
|
+
&& isQualified(r.backtest); // 回测攻守兼备 ⭐
|
|
423
|
+
};
|
|
424
|
+
const candidates = ok.filter(isBuyCandidate).slice(0, 3);
|
|
425
|
+
const medals = ['🥇', '🥈', '🥉'];
|
|
426
|
+
const condLine = '条件:综合≥55 · 基本面≥45 · 估值≥40 · 消息面无明显利空 · 当前技术=买入侧 · 回测⭐';
|
|
427
|
+
html += `<div style="margin-bottom:22px">
|
|
428
|
+
<div style="font-size:14px;font-weight:bold;color:#4caf50;margin-bottom:4px">✅ 买入候选(同时满足全部条件,按综合分取前三)</div>
|
|
429
|
+
<div style="font-size:11px;color:#777;margin-bottom:10px">${condLine}</div>`;
|
|
430
|
+
if (!candidates.length) {
|
|
431
|
+
html += `<div style="background:#1a2530;border:1px solid #3a2d2d;border-radius:10px;padding:16px;font-size:13px;color:#ff9800">
|
|
432
|
+
本次没有股票同时满足全部买入条件 —— 说明当前股票池里没有"质地好+不贵+技术转强+回测靠谱"俱全的标的,宁可空仓等待,也别凑合买。
|
|
433
|
+
</div>`;
|
|
434
|
+
} else {
|
|
435
|
+
html += `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">`;
|
|
436
|
+
candidates.forEach((r, i) => {
|
|
437
|
+
const v = r.valuation || {}, f = r.fundamental || {}, dims = r.dims || {};
|
|
438
|
+
const sc = signalColor(r.signal, r.totalScore);
|
|
439
|
+
const bar = (t, s) => `<div style="display:flex;justify-content:space-between;font-size:11px;margin:3px 0"><span style="color:#888">${t}</span><span style="color:${dimColor(s)};font-weight:bold">${s != null ? s : '—'}</span></div>`;
|
|
440
|
+
html += `<div onclick="quickAnalyze('${r.code}')" style="cursor:pointer;background:linear-gradient(135deg,#1a2e1f,#1a2530);border:1px solid ${i === 0 ? '#4caf50' : '#2d4a2d'};border-radius:12px;padding:16px">
|
|
441
|
+
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
|
442
|
+
<span style="font-size:22px">${medals[i]}</span>
|
|
443
|
+
<div><div style="font-size:16px;font-weight:bold;color:#fff">⭐ ${r.name}</div><div style="font-size:11px;color:#666">${r.code}</div></div>
|
|
444
|
+
</div>
|
|
445
|
+
<div style="text-align:center;margin:6px 0 8px"><span style="font-size:30px;font-weight:bold;color:${dimColor(r.composite)}">${r.composite}</span><span style="font-size:12px;color:#888"> /100 综合</span></div>
|
|
446
|
+
${bar('技术', dims.technical)}
|
|
447
|
+
${bar('估值' + (v.label ? '(' + v.label + ')' : ''), v.score)}
|
|
448
|
+
${bar('基本面' + (f.label ? '(' + f.label + ')' : ''), f.score)}
|
|
449
|
+
${bar('消息' + ((r.news && r.news.label) ? '(' + r.news.label + ')' : ''), r.news ? r.news.score : null)}
|
|
450
|
+
<div style="margin-top:8px;text-align:center"><span style="padding:2px 10px;border-radius:10px;font-size:12px;background:${sc}22;color:${sc}">${r.signal}</span>${r.backtest ? `<span style="margin-left:6px;font-size:11px;color:${cmpColor(r.backtest.compoundReturn)}">回测${r.backtest.compoundReturn > 0 ? '+' : ''}${r.backtest.compoundReturn}%</span>` : ''}</div>
|
|
451
|
+
</div>`;
|
|
452
|
+
});
|
|
453
|
+
html += `</div>`;
|
|
454
|
+
if (candidates.length < 3) html += `<div style="font-size:12px;color:#777;margin-top:8px">本次仅 ${candidates.length} 只满足全部买入条件,不硬凑三只。</div>`;
|
|
455
|
+
}
|
|
456
|
+
html += `<div style="font-size:11px;color:#ff8a80;margin-top:8px">注:这是按规则筛出的"候选",仍不含消息面;买前自行查公告、定仓位、设止损。</div></div>`;
|
|
457
|
+
|
|
458
|
+
html += `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
459
|
+
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #2a3a4a">
|
|
460
|
+
<th style="padding:10px 6px">#</th>
|
|
461
|
+
<th style="padding:10px 6px">名称</th>
|
|
462
|
+
<th style="padding:10px 6px;text-align:right">现价</th>
|
|
463
|
+
<th style="padding:10px 6px;text-align:right">涨跌</th>
|
|
464
|
+
<th style="padding:10px 6px;text-align:center" title="技术/估值/基本面 三维等权平均">综合分</th>
|
|
465
|
+
<th style="padding:10px 6px;text-align:center" title="技术面评分标准化(择时)">技术</th>
|
|
466
|
+
<th style="padding:10px 6px;text-align:center" title="PE/PB 历史分位,越便宜越高">估值</th>
|
|
467
|
+
<th style="padding:10px 6px;text-align:center" title="ROE/营收利润增速/负债,越优质越高">基本面</th>
|
|
468
|
+
<th style="padding:10px 6px;text-align:center" title="公告关键词:利好+/利空−,50为中性">消息</th>
|
|
469
|
+
<th style="padding:10px 6px">技术信号</th>
|
|
470
|
+
<th style="padding:10px 6px;text-align:right" title="回测满仓复利净收益(扣费)">复利回测</th>
|
|
471
|
+
</tr></thead><tbody>`;
|
|
472
|
+
|
|
473
|
+
ok.forEach((r, i) => {
|
|
474
|
+
const dir = r.changePct > 0 ? 'up' : r.changePct < 0 ? 'down' : 'flat';
|
|
475
|
+
const sc = signalColor(r.signal, r.totalScore);
|
|
476
|
+
const b = r.backtest;
|
|
477
|
+
const qualified = isQualified(b);
|
|
478
|
+
const rowBg = qualified ? 'background:rgba(255,213,79,0.08);' : '';
|
|
479
|
+
const v = r.valuation || {}, f = r.fundamental || {};
|
|
480
|
+
const vSub = v.pePercentile != null ? `PE${v.pePercentile}%位` : '';
|
|
481
|
+
const fSub = f.roe != null ? `ROE${f.roe.toFixed(0)}%` : '';
|
|
482
|
+
const btCell = b
|
|
483
|
+
? `<td style="padding:8px 6px;text-align:right;font-weight:bold;color:${cmpColor(b.compoundReturn)}">${b.compoundReturn > 0 ? '+' : ''}${b.compoundReturn}%</td>`
|
|
484
|
+
: `<td style="padding:8px 6px;text-align:center;color:#666;font-size:12px">无信号</td>`;
|
|
485
|
+
html += `<tr style="border-bottom:1px solid #1a2530;cursor:pointer;${rowBg}" onclick="quickAnalyze('${r.code}')" title="${qualified ? '回测攻守兼备 · ' : ''}点击查看完整分析">
|
|
486
|
+
<td style="padding:8px 6px;color:${i < 3 ? '#ffd54f' : '#666'};font-weight:${i < 3 ? 'bold' : 'normal'}">${i + 1}</td>
|
|
487
|
+
<td style="padding:8px 6px">${qualified ? '<span title="回测攻守兼备">⭐</span> ' : ''}<span style="color:#fff;font-weight:bold">${r.name}</span> <span style="color:#666;font-size:11px">${r.code}</span></td>
|
|
488
|
+
<td style="padding:8px 6px;text-align:right;color:#ddd">${(r.price != null ? r.price.toFixed(2) : '-')}</td>
|
|
489
|
+
<td style="padding:8px 6px;text-align:right" class="${dir}">${r.changePct > 0 ? '+' : ''}${r.changePct}%</td>
|
|
490
|
+
<td style="padding:8px 6px;text-align:center"><span style="font-size:18px;font-weight:bold;color:${dimColor(r.composite)}">${r.composite}</span></td>
|
|
491
|
+
${dimCell(r.dims ? r.dims.technical : null, '', '')}
|
|
492
|
+
${dimCell(v.score, v.label, vSub)}
|
|
493
|
+
${dimCell(f.score, f.label, fSub)}
|
|
494
|
+
${dimCell(r.news ? r.news.score : null, r.news ? r.news.label : null, r.news && r.news.hitCount ? r.news.hitCount + '条' : '')}
|
|
495
|
+
<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>
|
|
496
|
+
${btCell}
|
|
497
|
+
</tr>`;
|
|
498
|
+
});
|
|
499
|
+
html += `</tbody></table></div>`;
|
|
500
|
+
|
|
501
|
+
if (bad.length) {
|
|
502
|
+
html += `<div style="margin-top:14px;font-size:12px;color:#666">
|
|
503
|
+
未能分析:${bad.map(b => `${b.name}(${b.code}) ${b.error}`).join(';')}
|
|
504
|
+
</div>`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
html += `<div class="disclaimer">
|
|
508
|
+
免责声明:以上为技术面评分排名,不构成投资建议。投资有风险,入市需谨慎。<br>
|
|
509
|
+
点击任意一行可查看该股完整分析与回测。
|
|
510
|
+
</div>`;
|
|
511
|
+
|
|
512
|
+
lastRankHTML = html; // 存起来,点进个股后可一键返回,无需重算
|
|
513
|
+
resultDiv.innerHTML = html;
|
|
514
|
+
resultDiv.classList.add('active');
|
|
515
|
+
}
|
|
516
|
+
|
|
288
517
|
async function doAnalyze(skipBacktest = false) {
|
|
289
518
|
let code = input.value.trim();
|
|
290
519
|
if (!code) return;
|
|
@@ -338,33 +567,59 @@
|
|
|
338
567
|
|
|
339
568
|
let html = '';
|
|
340
569
|
|
|
570
|
+
// 若是从排名点进来的,显示一键返回(直接复用已渲染结果,不重新计算)
|
|
571
|
+
if (lastRankHTML) {
|
|
572
|
+
html += `<button onclick="backToRank()" style="margin-bottom:14px;padding:8px 16px;border:1px solid #5a3dbf;border-radius:8px;background:transparent;color:#b39dff;font-size:13px;cursor:pointer">← 返回排名</button>`;
|
|
573
|
+
}
|
|
574
|
+
|
|
341
575
|
// 回测结果卡片
|
|
342
576
|
if (backtestData && !backtestData.error) {
|
|
343
577
|
const bt = backtestData;
|
|
344
|
-
const long = bt.long || {total:0,winRate:0,avgReturn:0,totalReturn:0,avgWin:0,avgLoss:0,avgHoldDays:0};
|
|
578
|
+
const long = bt.long || {total:0,winRate:0,avgReturn:0,totalReturn:0,compoundReturn:0,maxDrawdown:0,profitFactor:0,avgWin:0,avgLoss:0,avgHoldDays:0};
|
|
345
579
|
const gradeColor = bt.grade === '优秀' ? '#4caf50' : bt.grade === '良好' ? '#2196f3' : bt.grade === '一般' ? '#ff9800' : '#f44336';
|
|
346
580
|
const winRateColor = long.winRate >= 55 ? '#4caf50' : long.winRate >= 50 ? '#ff9800' : '#f44336';
|
|
347
581
|
const avgReturnColor = long.avgReturn > 1.5 ? '#4caf50' : long.avgReturn > 0 ? '#ff9800' : '#f44336';
|
|
582
|
+
const ddColor = long.maxDrawdown <= 10 ? '#4caf50' : long.maxDrawdown <= 25 ? '#ff9800' : '#f44336';
|
|
583
|
+
const pfColor = long.profitFactor >= 1.5 ? '#4caf50' : long.profitFactor >= 1 ? '#ff9800' : '#f44336';
|
|
584
|
+
const compoundColor = long.compoundReturn > 0 ? '#4caf50' : '#f44336';
|
|
348
585
|
const safeName = (bt.name||'').replace(/['"<>&]/g, '');
|
|
586
|
+
const costNote = bt.costs ? `已扣费(单次往返约${bt.costs.roundTripPct}%)` : '已扣费';
|
|
349
587
|
html += `<div style="background:#1a2530;border:1px solid ${gradeColor};margin-bottom:20px;padding:20px;border-radius:10px">
|
|
350
588
|
<h3 style="color:${gradeColor};margin-bottom:12px;font-size:15px">回测验证: ${safeName} (${bt.code}) — 评级: ${bt.grade}</h3>
|
|
351
|
-
<div style="font-size:12px;color:#888;margin-bottom:10px">数据: ${bt.dataRange || '-'} | 引擎: ${bt.engine || '-'} | 做多交易 ${long.total} 笔 | 平均持仓 ${long.avgHoldDays || 0}
|
|
352
|
-
${long.total > 0 ? `<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:
|
|
589
|
+
<div style="font-size:12px;color:#888;margin-bottom:10px">数据: ${bt.dataRange || '-'} | 引擎: ${bt.engine || '-'} | 做多交易 ${long.total} 笔 | 平均持仓 ${long.avgHoldDays || 0} 天 | 收益口径: ${costNote}</div>
|
|
590
|
+
${long.total > 0 ? `<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:10px">
|
|
353
591
|
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
354
592
|
<div style="font-size:11px;color:#888">做多胜率</div>
|
|
355
593
|
<div style="font-size:18px;font-weight:bold;color:${winRateColor}">${long.winRate}%</div>
|
|
356
594
|
<div style="font-size:11px;color:#888">${long.wins||0}胜 / ${long.losses||0}负</div>
|
|
357
595
|
</div>
|
|
358
596
|
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
359
|
-
<div style="font-size:11px;color:#888"
|
|
597
|
+
<div style="font-size:11px;color:#888">单笔均净收益</div>
|
|
360
598
|
<div style="font-size:18px;font-weight:bold;color:${avgReturnColor}">${long.avgReturn}%</div>
|
|
361
|
-
<div style="font-size:11px;color:#888"
|
|
599
|
+
<div style="font-size:11px;color:#888">毛 ${long.grossAvgReturn != null ? long.grossAvgReturn : '-'}%</div>
|
|
362
600
|
</div>
|
|
363
601
|
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
364
602
|
<div style="font-size:11px;color:#888">盈亏比</div>
|
|
365
603
|
<div style="font-size:18px;font-weight:bold;color:#fff">${long.avgWin || 0} : ${Math.abs(long.avgLoss || 0)}</div>
|
|
366
604
|
<div style="font-size:11px;color:#888">均盈 : 均亏</div>
|
|
367
605
|
</div>
|
|
606
|
+
</div>
|
|
607
|
+
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:12px">
|
|
608
|
+
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
609
|
+
<div style="font-size:11px;color:#888">复利累计收益</div>
|
|
610
|
+
<div style="font-size:18px;font-weight:bold;color:${compoundColor}">${long.compoundReturn != null ? long.compoundReturn : '-'}%</div>
|
|
611
|
+
<div style="font-size:11px;color:#888">满仓滚动</div>
|
|
612
|
+
</div>
|
|
613
|
+
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
614
|
+
<div style="font-size:11px;color:#888">最大回撤</div>
|
|
615
|
+
<div style="font-size:18px;font-weight:bold;color:${ddColor}">${long.maxDrawdown != null ? long.maxDrawdown : '-'}%</div>
|
|
616
|
+
<div style="font-size:11px;color:#888">净值峰值→谷底</div>
|
|
617
|
+
</div>
|
|
618
|
+
<div style="text-align:center;padding:8px;background:#141e28;border-radius:6px">
|
|
619
|
+
<div style="font-size:11px;color:#888">盈利因子</div>
|
|
620
|
+
<div style="font-size:18px;font-weight:bold;color:${pfColor}">${long.profitFactor != null ? long.profitFactor : '-'}</div>
|
|
621
|
+
<div style="font-size:11px;color:#888">净盈 / 净亏</div>
|
|
622
|
+
</div>
|
|
368
623
|
</div>` : `<div style="padding:12px;text-align:center;color:#888;font-size:13px;background:#141e28;border-radius:6px;margin-bottom:12px">回测周期内无做多入场信号</div>`}
|
|
369
624
|
<button onclick="saveToShortcuts('${bt.code}','${safeName}','${long.winRate}','${bt.grade}')" style="padding:8px 16px;border:1px solid ${gradeColor};border-radius:6px;background:transparent;color:${gradeColor};cursor:pointer;font-size:13px">+ 加入快捷列表</button>
|
|
370
625
|
<span style="font-size:11px;color:#666;margin-left:10px">加入后可在顶部快速访问</span>
|
|
@@ -406,6 +661,32 @@
|
|
|
406
661
|
<div class="score-labels"><span>强烈卖出</span><span>观望</span><span>强烈买入</span></div>
|
|
407
662
|
</div>`;
|
|
408
663
|
|
|
664
|
+
// 三维综合(技术/估值/基本面)
|
|
665
|
+
if (data.composite != null) {
|
|
666
|
+
const dimColor = s => s == null ? '#666' : s >= 65 ? '#4caf50' : s >= 45 ? '#ff9800' : '#f44336';
|
|
667
|
+
const val = data.valuation || {}, fund = data.fundamental || {};
|
|
668
|
+
const dims = data.dims || {};
|
|
669
|
+
const dimBox = (title, score, label, hint) => `
|
|
670
|
+
<div style="flex:1;min-width:120px;background:#141e28;border-radius:10px;padding:14px;text-align:center;border-top:3px solid ${dimColor(score)}">
|
|
671
|
+
<div style="font-size:12px;color:#888;margin-bottom:4px">${title}</div>
|
|
672
|
+
<div style="font-size:24px;font-weight:bold;color:${dimColor(score)}">${score != null ? score : '—'}</div>
|
|
673
|
+
<div style="font-size:11px;color:#aaa">${label || (score == null ? '无数据' : '')}</div>
|
|
674
|
+
<div style="font-size:10px;color:#666;margin-top:2px">${hint || ''}</div>
|
|
675
|
+
</div>`;
|
|
676
|
+
html += `<div style="background:#1a2530;border:1px solid #2a3a4a;border-radius:12px;padding:18px;margin-bottom:20px">
|
|
677
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
|
678
|
+
<span style="font-size:15px;font-weight:bold;color:#b39dff">三维综合分 ${data.composite}<span style="font-size:12px;color:#888;font-weight:normal"> / 100(技术·估值·基本面 等权)</span></span>
|
|
679
|
+
<span style="font-size:11px;color:#666">估值/基本面为当前值,不参与回测</span>
|
|
680
|
+
</div>
|
|
681
|
+
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
682
|
+
${dimBox('技术面', dims.technical, '', '评分标准化·择时')}
|
|
683
|
+
${dimBox('估值', val.score, val.label, val.pePercentile != null ? `PE ${val.pePercentile}% 分位` : '')}
|
|
684
|
+
${dimBox('基本面', fund.score, fund.label, fund.roe != null ? `ROE ${fund.roe.toFixed(1)}%` : '')}
|
|
685
|
+
${dimBox('消息面', (data.news || {}).score, (data.news || {}).label, (data.news && data.news.hitCount) ? `命中 ${data.news.hitCount} 条` : '公告关键词')}
|
|
686
|
+
</div>
|
|
687
|
+
</div>`;
|
|
688
|
+
}
|
|
689
|
+
|
|
409
690
|
// 操作建议大卡片(最显眼)
|
|
410
691
|
const posAdvice = (rr && rr.positionAdvice) || '';
|
|
411
692
|
const holdAdvice = summary.totalScore >= 10 ? '建议持仓5-10天,趋势跟踪' : summary.totalScore >= 5 ? '建议持仓3-5天,短线波段' : summary.totalScore >= 1 ? '建议持仓1-3天,快进快出' : '不建议开仓';
|
|
@@ -524,9 +805,31 @@
|
|
|
524
805
|
</div>`;
|
|
525
806
|
}
|
|
526
807
|
|
|
808
|
+
// 估值 / 基本面 明细(与技术指标并列的可折叠模块)
|
|
809
|
+
const extraSections = [
|
|
810
|
+
{ title: '估值分析', d: data.valuation },
|
|
811
|
+
{ title: '基本面', d: data.fundamental },
|
|
812
|
+
{ title: '消息面(公告关键词)', d: data.news },
|
|
813
|
+
];
|
|
814
|
+
for (const sec of extraSections) {
|
|
815
|
+
if (!sec.d || !sec.d.signals) continue;
|
|
816
|
+
const score = sec.d.score;
|
|
817
|
+
const scoreText = score == null ? '<span class="section-score zero">无数据</span>'
|
|
818
|
+
: `<span class="section-score ${score >= 65 ? 'positive' : score >= 45 ? 'zero' : 'negative'}">${score}/100 ${sec.d.label || ''}</span>`;
|
|
819
|
+
html += `<div class="section">
|
|
820
|
+
<div class="section-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'">
|
|
821
|
+
<span class="section-title">${sec.title}</span>
|
|
822
|
+
${scoreText}
|
|
823
|
+
</div>
|
|
824
|
+
<div class="section-body">
|
|
825
|
+
${sec.d.signals.map(s => `<div class="signal-item">${s}</div>`).join('')}
|
|
826
|
+
</div>
|
|
827
|
+
</div>`;
|
|
828
|
+
}
|
|
829
|
+
|
|
527
830
|
// 免责声明
|
|
528
831
|
html += `<div class="disclaimer">
|
|
529
|
-
|
|
832
|
+
免责声明:以上分析基于技术面 + 估值 + 基本面,不含消息面,不构成投资建议。投资有风险,入市需谨慎。<br>
|
|
530
833
|
数据来源:新浪财经(实时) / 腾讯财经(历史K线) | 分析时间: ${rt.time}
|
|
531
834
|
</div>`;
|
|
532
835
|
|
package/news.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 消息面模块 — 数据来自东方财富公告接口(np-anotice-stock)
|
|
3
|
+
*
|
|
4
|
+
* 用关键词对最近的公告标题做利好/利空初筛(减持/问询=利空,预增/回购/中标=利好),
|
|
5
|
+
* 按时间衰减加权,聚合成 0~100 分(50=中性)。利空权重高于利好(风险更重要)。
|
|
6
|
+
*
|
|
7
|
+
* 特点:
|
|
8
|
+
* - 关键词法,免费、快(每只 1 次 HTTP,可并发);带 20 分钟内存缓存
|
|
9
|
+
* - 仅"当前快照",不参与回测(免费源无历史时点情绪)
|
|
10
|
+
* - LLM 精判留给单股详情页按需触发,不在批量里跑
|
|
11
|
+
*
|
|
12
|
+
* 注:公告比新闻更干净——高信号事件(减持/问询/预增/中标)都在公告里。
|
|
13
|
+
*/
|
|
14
|
+
const { getJSON, toSecuCode } = require('./http-util');
|
|
15
|
+
|
|
16
|
+
// 利好 / 利空关键词(命中即计分;中性/行政事项不匹配)
|
|
17
|
+
const POSITIVE = ['预增', '预盈', '扭亏', '增持', '回购', '中标', '订单', '合同', '战略合作',
|
|
18
|
+
'收购', '获批', '分红', '派息', '业绩快报', '量产'];
|
|
19
|
+
const NEGATIVE = ['减持', '问询函', '关注函', '立案', '处罚', '违规', '诉讼', '仲裁', '商誉减值',
|
|
20
|
+
'计提', '预减', '预亏', '亏损', '退市', '风险警示', '*ST', 'ST', '质押', '冻结', '终止',
|
|
21
|
+
'下修', '监管', '警示函', '更正', '延期'];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 上下文护栏:命中的关键词在某些语境下不算利好/利空,返回 true 表示应忽略。
|
|
25
|
+
* 例:"回购"出现在股权激励/限制性股票/期权语境里,是行政事项而非真实回购利好。
|
|
26
|
+
*/
|
|
27
|
+
function isFalsePositive(kw, title) {
|
|
28
|
+
if (kw === '回购' && /激励|限制性|期权/.test(title)) return true;
|
|
29
|
+
if (kw === '增持' && /激励|限制性|期权/.test(title)) return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 'sz001309' → '001309';非 A 股返回 null */
|
|
34
|
+
function toNum(code) {
|
|
35
|
+
return toSecuCode(code) ? code.replace(/^(sh|sz)/i, '') : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function fetchNews(code) {
|
|
39
|
+
const num = toNum(code);
|
|
40
|
+
if (!num) return null; // 非 A 股
|
|
41
|
+
const url = 'https://np-anotice-stock.eastmoney.com/api/security/ann'
|
|
42
|
+
+ `?sr=-1&page_size=30&page_index=1&ann_type=A&client_source=web&stock_list=${num}`;
|
|
43
|
+
const j = await getJSON(url, { Referer: 'https://data.eastmoney.com/' });
|
|
44
|
+
const list = j && j.data && j.data.list;
|
|
45
|
+
if (!list) return null;
|
|
46
|
+
return list
|
|
47
|
+
.map(a => ({ date: (a.notice_date || '').slice(0, 10), title: a.title || '' }))
|
|
48
|
+
.filter(a => a.title);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ===== 20 分钟内存缓存(公告不会分秒变,排名常重跑) =====
|
|
52
|
+
const _cache = new Map();
|
|
53
|
+
const TTL = 20 * 60 * 1000;
|
|
54
|
+
async function fetchNewsCached(code) {
|
|
55
|
+
const hit = _cache.get(code);
|
|
56
|
+
if (hit && Date.now() - hit.ts < TTL) return hit.data;
|
|
57
|
+
// 批量排名时多接口并发,公告接口偶发失败 → 失败后短暂等待重试一次
|
|
58
|
+
let data = null;
|
|
59
|
+
for (let attempt = 0; attempt < 2 && !data; attempt++) {
|
|
60
|
+
if (attempt > 0) await new Promise(r => setTimeout(r, 350));
|
|
61
|
+
try { data = await fetchNews(code); } catch (e) { data = null; }
|
|
62
|
+
}
|
|
63
|
+
if (data) _cache.set(code, { ts: Date.now(), data }); // 只缓存成功结果
|
|
64
|
+
return data;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 距今天数(粗略,按日期字符串) */
|
|
68
|
+
function daysAgo(dateStr) {
|
|
69
|
+
const d = new Date(dateStr + 'T00:00:00');
|
|
70
|
+
if (Number.isNaN(d.getTime())) return 999;
|
|
71
|
+
return Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
72
|
+
}
|
|
73
|
+
function recencyWeight(days) {
|
|
74
|
+
if (days <= 7) return 1.0;
|
|
75
|
+
if (days <= 30) return 0.6;
|
|
76
|
+
if (days <= 90) return 0.3;
|
|
77
|
+
return 0; // 超过 90 天不计入
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 消息面打分,返回 0~100 + 命中的利好/利空信号 */
|
|
81
|
+
function scoreNews(items) {
|
|
82
|
+
if (!items) return { score: null, signals: ['无消息数据(非A股或接口失败)'] };
|
|
83
|
+
if (!items.length) return { score: 50, signals: ['近期无公告'] };
|
|
84
|
+
|
|
85
|
+
let score = 50;
|
|
86
|
+
const hits = [];
|
|
87
|
+
for (const it of items) {
|
|
88
|
+
const w = recencyWeight(daysAgo(it.date));
|
|
89
|
+
if (w === 0) continue;
|
|
90
|
+
const neg = NEGATIVE.find(k => it.title.includes(k));
|
|
91
|
+
const pos = POSITIVE.find(k => it.title.includes(k) && !isFalsePositive(k, it.title));
|
|
92
|
+
if (neg) { score -= 12 * w; hits.push({ date: it.date, title: it.title, tag: '利空', kw: neg }); }
|
|
93
|
+
else if (pos) { score += 7 * w; hits.push({ date: it.date, title: it.title, tag: '利好', kw: pos }); }
|
|
94
|
+
}
|
|
95
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
96
|
+
|
|
97
|
+
const signals = [];
|
|
98
|
+
if (!hits.length) {
|
|
99
|
+
signals.push('近期公告以常规事项为主,无明显利好/利空');
|
|
100
|
+
} else {
|
|
101
|
+
// 利空优先展示
|
|
102
|
+
hits.sort((a, b) => (a.tag === '利空' ? -1 : 1) - (b.tag === '利空' ? -1 : 1));
|
|
103
|
+
for (const h of hits.slice(0, 6)) signals.push(`[${h.tag}] ${h.date} ${h.title}`);
|
|
104
|
+
}
|
|
105
|
+
const label = score >= 60 ? '偏多' : score >= 40 ? '中性' : '偏空';
|
|
106
|
+
return { score, signals, label, hitCount: hits.length };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { fetchNews, fetchNewsCached, scoreNews };
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kamuira/stock-analyzer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"preferGlobal": true,
|
|
5
|
-
"description": "A
|
|
5
|
+
"description": "A股/台股综合分析工具 - 技术面+估值+基本面+消息面四维评分、批量排名、含成本回测、买卖建议",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"stock-analyze": "bin/analyze.js",
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
"RSI",
|
|
25
25
|
"KDJ",
|
|
26
26
|
"ADX",
|
|
27
|
-
"backtest"
|
|
27
|
+
"backtest",
|
|
28
|
+
"valuation",
|
|
29
|
+
"fundamental-analysis",
|
|
30
|
+
"stock-ranking"
|
|
28
31
|
],
|
|
29
32
|
"author": "guiwzh",
|
|
30
33
|
"license": "MIT",
|
|
@@ -44,6 +47,10 @@
|
|
|
44
47
|
"dev.js",
|
|
45
48
|
"indicators.js",
|
|
46
49
|
"scoring.js",
|
|
50
|
+
"valuation.js",
|
|
51
|
+
"fundamentals.js",
|
|
52
|
+
"news.js",
|
|
53
|
+
"http-util.js",
|
|
47
54
|
"index.html",
|
|
48
55
|
"README.md"
|
|
49
56
|
],
|