@kamuira/stock-analyzer 1.3.4 → 1.4.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/analyze.js +7 -2
- package/index.html +407 -165
- package/package.json +4 -2
- package/screener.js +86 -0
- package/sectors.js +118 -0
- package/server.js +39 -0
package/analyze.js
CHANGED
|
@@ -82,12 +82,16 @@ function proxiedText(url) {
|
|
|
82
82
|
* https_proxy 环境变量,正好解决国内访问境外源需走代理的问题。
|
|
83
83
|
* curl 不存在时(罕见)抛 ENOENT,由 twFetch 回退到 Node 路径。
|
|
84
84
|
*/
|
|
85
|
-
function curlText(url) {
|
|
85
|
+
function curlText(url, extraHeaders = [], opts = {}) {
|
|
86
86
|
return new Promise((resolve, reject) => {
|
|
87
|
+
const headerArgs = [];
|
|
88
|
+
for (const h of extraHeaders) headerArgs.push('-H', h);
|
|
89
|
+
// noProxy: 境内源(如东财 push2)强制直连,避免被台股的境外代理误带偏
|
|
90
|
+
const proxyArgs = opts.noProxy ? ['--noproxy', '*'] : [];
|
|
87
91
|
// 经代理访问境外源首连常慢/偶发 SSL 重置(exit 35),故给足超时并自动重试。
|
|
88
92
|
execFile('curl', ['-s', '--compressed', '--connect-timeout', '8', '--max-time', '20',
|
|
89
93
|
'--retry', '3', '--retry-delay', '1', '--retry-all-errors',
|
|
90
|
-
'-A', 'Mozilla/5.0', '-H', 'Accept: */*', url],
|
|
94
|
+
'-A', 'Mozilla/5.0', '-H', 'Accept: */*', ...headerArgs, ...proxyArgs, url],
|
|
91
95
|
{ maxBuffer: 20 * 1024 * 1024 }, (err, stdout) => {
|
|
92
96
|
if (err) { reject(err); return; }
|
|
93
97
|
if (!stdout) { reject(new Error('curl 返回空响应')); return; }
|
|
@@ -445,4 +449,5 @@ if (require.main === module) {
|
|
|
445
449
|
module.exports = {
|
|
446
450
|
fetchRealtime, fetchHistory, fetchTWRealtime, fetchTWHistory,
|
|
447
451
|
getMarketEnvironment, resolveCode, formatReport, WATCH_LIST, main,
|
|
452
|
+
curlText,
|
|
448
453
|
};
|
package/index.html
CHANGED
|
@@ -205,100 +205,22 @@
|
|
|
205
205
|
<input type="text" id="codeInput" placeholder="A股: 名称/代码/拼音 | 台股: 4位代码如2330" autocomplete="off">
|
|
206
206
|
<button id="analyzeBtn" onclick="doAnalyze()">分析</button>
|
|
207
207
|
<button id="rankBtn" onclick="doRank()" style="background:#5a3dbf">📊 批量评分排名</button>
|
|
208
|
+
<button id="sectorBtn" onclick="doSectors()" style="background:#bf3d5a">🔥 今日热门板块</button>
|
|
208
209
|
</div>
|
|
209
210
|
|
|
210
|
-
<div class="shortcuts">
|
|
211
|
-
<
|
|
212
|
-
<button onclick="quickAnalyze('sh603893')">瑞芯微</button>
|
|
213
|
-
<button onclick="quickAnalyze('sz300750')">宁德时代</button>
|
|
214
|
-
<button onclick="quickAnalyze('sz300274')">阳光电源</button>
|
|
215
|
-
<button onclick="quickAnalyze('sh603698')">航天工程</button>
|
|
216
|
-
<button onclick="quickAnalyze('sh601138')">工业富联</button>
|
|
217
|
-
<button onclick="quickAnalyze('sh600011')">华能国际</button>
|
|
218
|
-
<button onclick="quickAnalyze('sh601600')">中国铝业</button>
|
|
219
|
-
<button onclick="quickAnalyze('sz002138')">顺络电子</button>
|
|
220
|
-
<button onclick="quickAnalyze('sh603986')">兆易创新</button>
|
|
221
|
-
<button onclick="quickAnalyze('sz002716')">湖南白银</button>
|
|
222
|
-
<button onclick="quickAnalyze('sh603256')">宏和科技</button>
|
|
223
|
-
<button onclick="quickAnalyze('sz001309')">德明利</button>
|
|
224
|
-
<button onclick="quickAnalyze('sh601899')">紫金矿业</button>
|
|
225
|
-
<button onclick="quickAnalyze('sz000426')">兴业银锡</button>
|
|
226
|
-
<button onclick="quickAnalyze('sz002428')">云南锗业</button>
|
|
227
|
-
<button onclick="quickAnalyze('sh600259')">中稀有色</button>
|
|
228
|
-
<button onclick="quickAnalyze('sh600362')">江西铜业</button>
|
|
229
|
-
<button onclick="quickAnalyze('sh600206')">有研新材</button>
|
|
230
|
-
<button onclick="quickAnalyze('sh600111')">北方稀土</button>
|
|
231
|
-
<button onclick="quickAnalyze('sh601318')">中国平安</button>
|
|
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>
|
|
238
|
-
<button onclick="quickAnalyze('sh600309')">万华化学</button>
|
|
239
|
-
<button onclick="quickAnalyze('sz300170')">汉得信息</button>
|
|
240
|
-
<button onclick="quickAnalyze('sh605589')">圣泉集团</button>
|
|
241
|
-
<button onclick="quickAnalyze('sz300308')">中际旭创</button>
|
|
242
|
-
<button onclick="quickAnalyze('sz002463')">沪电股份</button>
|
|
243
|
-
<button onclick="quickAnalyze('sh603259')">药明康德</button>
|
|
244
|
-
<button onclick="quickAnalyze('sz002371')">北方华创</button>
|
|
245
|
-
<button onclick="quickAnalyze('sz300502')">新易盛</button>
|
|
246
|
-
<button onclick="quickAnalyze('sh600487')">亨通光电</button>
|
|
247
|
-
<button onclick="quickAnalyze('sh601869')">长飞光纤</button>
|
|
248
|
-
<button onclick="quickAnalyze('sh600869')">远东股份</button>
|
|
249
|
-
<button onclick="quickAnalyze('sz002340')">格林美</button>
|
|
250
|
-
<button onclick="quickAnalyze('sh600900')">长江电力</button>
|
|
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>
|
|
211
|
+
<div class="shortcuts" style="justify-content:center">
|
|
212
|
+
<select id="quickPick" style="padding:9px 14px;border:1px solid #2a3a4a;border-radius:8px;background:#1a2a3a;color:#fff;font-size:13px;max-width:340px;cursor:pointer;outline:none"></select>
|
|
293
213
|
</div>
|
|
294
|
-
<div class="shortcuts" id="customShortcuts"></div>
|
|
295
214
|
|
|
296
215
|
<div id="rankPanel" style="max-width:680px;margin:0 auto 24px;background:#141e28;border:1px solid #1f2d3a;border-radius:10px;padding:14px 16px">
|
|
297
|
-
<div style="font-size:13px;color:#b39dff;margin-bottom:8px;font-weight:bold">📊 排名股票池
|
|
298
|
-
<
|
|
216
|
+
<div style="font-size:13px;color:#b39dff;margin-bottom:8px;font-weight:bold">📊 排名股票池 — 选一个分组来排名,或新建自己的分组</div>
|
|
217
|
+
<div id="groupTabs" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"></div>
|
|
218
|
+
<textarea id="rankListInput" rows="2" 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>
|
|
299
219
|
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;flex-wrap:wrap">
|
|
300
|
-
<button onclick="doRank()" style="padding:8px 18px;border:none;border-radius:8px;background:#5a3dbf;color:#fff;font-size:13px;cursor:pointer"
|
|
301
|
-
<button onclick="
|
|
220
|
+
<button onclick="doRank()" style="padding:8px 18px;border:none;border-radius:8px;background:#5a3dbf;color:#fff;font-size:13px;cursor:pointer">📊 排名本组</button>
|
|
221
|
+
<button id="saveGroupBtn" onclick="saveCurrentGroup()" style="padding:8px 14px;border:1px solid #3a5a3a;border-radius:8px;background:transparent;color:#7bd88f;font-size:12px;cursor:pointer">💾 保存分组</button>
|
|
222
|
+
<button id="delGroupBtn" onclick="deleteCurrentGroup()" style="padding:8px 14px;border:1px solid #5a3a3a;border-radius:8px;background:transparent;color:#e57373;font-size:12px;cursor:pointer">🗑 删除本组</button>
|
|
223
|
+
<button onclick="resetRankList()" style="padding:8px 14px;border:1px solid #2a3a4a;border-radius:8px;background:transparent;color:#aaa;font-size:12px;cursor:pointer">清空</button>
|
|
302
224
|
<span id="rankPoolHint" style="font-size:12px;color:#666"></span>
|
|
303
225
|
</div>
|
|
304
226
|
</div>
|
|
@@ -321,34 +243,6 @@
|
|
|
321
243
|
const errorDiv = document.getElementById('error');
|
|
322
244
|
const resultDiv = document.getElementById('result');
|
|
323
245
|
|
|
324
|
-
// 从 localStorage 加载自定义快捷按钮
|
|
325
|
-
function loadCustomShortcuts() {
|
|
326
|
-
let saved = [];
|
|
327
|
-
try { saved = JSON.parse(localStorage.getItem('customStocks') || '[]'); } catch(e) { saved = []; }
|
|
328
|
-
const container = document.getElementById('customShortcuts');
|
|
329
|
-
container.innerHTML = '';
|
|
330
|
-
for (const s of saved) {
|
|
331
|
-
const b = document.createElement('button');
|
|
332
|
-
b.textContent = s.name;
|
|
333
|
-
b.onclick = () => quickAnalyze(s.code);
|
|
334
|
-
b.title = `${s.code} | 5日胜率${s.winRate}% | 评级:${s.grade}`;
|
|
335
|
-
if (s.grade === '优秀') b.style.borderColor = '#4caf50';
|
|
336
|
-
else if (s.grade === '良好') b.style.borderColor = '#2196f3';
|
|
337
|
-
else if (s.grade === '较差') b.style.borderColor = '#f44336';
|
|
338
|
-
container.appendChild(b);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
loadCustomShortcuts();
|
|
342
|
-
|
|
343
|
-
function saveToShortcuts(code, name, winRate, grade) {
|
|
344
|
-
let saved = [];
|
|
345
|
-
try { saved = JSON.parse(localStorage.getItem('customStocks') || '[]'); } catch(e) { saved = []; }
|
|
346
|
-
if (saved.find(s => s.code === code)) return;
|
|
347
|
-
saved.push({ code, name: name.replace(/['"<>]/g, ''), winRate, grade });
|
|
348
|
-
localStorage.setItem('customStocks', JSON.stringify(saved));
|
|
349
|
-
loadCustomShortcuts();
|
|
350
|
-
}
|
|
351
|
-
|
|
352
246
|
input.addEventListener('keypress', e => { if (e.key === 'Enter') doAnalyze(); });
|
|
353
247
|
|
|
354
248
|
function quickAnalyze(code) {
|
|
@@ -356,57 +250,206 @@
|
|
|
356
250
|
doAnalyze(false); // 所有快捷按钮都显示回测
|
|
357
251
|
}
|
|
358
252
|
|
|
359
|
-
//
|
|
253
|
+
// 批量排名默认股票池(代码),与下方 STOCK_NAMES 一一对应;速选下拉框也由此生成
|
|
360
254
|
const DEFAULT_RANK_LIST = [
|
|
361
|
-
'sz002049','sh603893','sz300750','sz300274','sh603698','sh601138',
|
|
362
|
-
'
|
|
363
|
-
'
|
|
364
|
-
'
|
|
365
|
-
'
|
|
366
|
-
'
|
|
367
|
-
'
|
|
368
|
-
'
|
|
369
|
-
'
|
|
370
|
-
'
|
|
371
|
-
'
|
|
372
|
-
'
|
|
373
|
-
'
|
|
255
|
+
'sz002049','sh603893','sz300750','sz300274','sh603698','sh601138','sh600011','sh601600',
|
|
256
|
+
'sz002138','sh603986','sz002716','sh603256','sz001309','sh601899','sz000426','sz002428',
|
|
257
|
+
'sh600259','sh600362','sh600206','sh600111','sh601318','sh601066','sz000510','sh601021',
|
|
258
|
+
'sz000657','sh600027','sh603659','sh600309','sz300170','sh605589','sz300308','sz002463',
|
|
259
|
+
'sh603259','sz002371','sz300502','sh600487','sh601869','sh600869','sz002340','sh600900',
|
|
260
|
+
'sz002916','sz002378','sh600699','sz002709','sz000338','sz002125','sh600089','sh603124',
|
|
261
|
+
'sz000983','sz001314','sh600158','sz000534','sz002747','sz002050','sz002167','sz002931',
|
|
262
|
+
'sh600458','sh603500','sz000021','sz002028','sh600563','sz000725','sh605358','sz002938',
|
|
263
|
+
'sh600522','sz002213','sh600226','sz001389','sz002179','sz002491','sh601231','sz002156',
|
|
264
|
+
'sz300476','sh600406','sz000807','sh600961','sh605117','sh603271','sh603129','sh603288',
|
|
265
|
+
'sh603277','sh603606','sz002466','sz002460','sz002240','sz002552','sz002896','sz002080',
|
|
266
|
+
'sh600176','sh603217','sh603078','sz002643','sz002741','sz000630','sh600110','sh601208',
|
|
267
|
+
'sz002636','sh603186','sh600183','sz002384','sh600703','sz002475'
|
|
374
268
|
];
|
|
375
269
|
|
|
270
|
+
// 代码 -> 名称,供"清单速选"下拉框显示(排名结果的名称仍以实时接口为准)
|
|
271
|
+
const STOCK_NAMES = {
|
|
272
|
+
'sz002049':'紫光国微', 'sh603893':'瑞芯微', 'sz300750':'宁德时代', 'sz300274':'阳光电源',
|
|
273
|
+
'sh603698':'航天工程', 'sh601138':'工业富联', 'sh600011':'华能国际', 'sh601600':'中国铝业',
|
|
274
|
+
'sz002138':'顺络电子', 'sh603986':'兆易创新', 'sz002716':'湖南白银', 'sh603256':'宏和科技',
|
|
275
|
+
'sz001309':'德明利', 'sh601899':'紫金矿业', 'sz000426':'兴业银锡', 'sz002428':'云南锗业',
|
|
276
|
+
'sh600259':'中稀有色', 'sh600362':'江西铜业', 'sh600206':'有研新材', 'sh600111':'北方稀土',
|
|
277
|
+
'sh601318':'中国平安', 'sh601066':'中信建投', 'sz000510':'新金路', 'sh601021':'春秋航空',
|
|
278
|
+
'sz000657':'中钨高新', 'sh600027':'华电国际', 'sh603659':'璞泰来', 'sh600309':'万华化学',
|
|
279
|
+
'sz300170':'汉得信息', 'sh605589':'圣泉集团', 'sz300308':'中际旭创', 'sz002463':'沪电股份',
|
|
280
|
+
'sh603259':'药明康德', 'sz002371':'北方华创', 'sz300502':'新易盛', 'sh600487':'亨通光电',
|
|
281
|
+
'sh601869':'长飞光纤', 'sh600869':'远东股份', 'sz002340':'格林美', 'sh600900':'长江电力',
|
|
282
|
+
'sz002916':'深南电路', 'sz002378':'章源钨业', 'sh600699':'均胜电子', 'sz002709':'天赐材料',
|
|
283
|
+
'sz000338':'潍柴动力', 'sz002125':'湘潭电化', 'sh600089':'特变电工', 'sh603124':'江南新材',
|
|
284
|
+
'sz000983':'山西焦煤', 'sz001314':'亿道信息', 'sh600158':'中体产业', 'sz000534':'万泽股份',
|
|
285
|
+
'sz002747':'埃斯顿', 'sz002050':'三花智控', 'sz002167':'东方锆业', 'sz002931':'锋龙股份',
|
|
286
|
+
'sh600458':'时代新材', 'sh603500':'祥和实业', 'sz000021':'深科技', 'sz002028':'思源电气',
|
|
287
|
+
'sh600563':'法拉电子', 'sz000725':'京东方A', 'sh605358':'立昂微', 'sz002938':'鹏鼎控股',
|
|
288
|
+
'sh600522':'中天科技', 'sz002213':'大为股份', 'sh600226':'亨通股份', 'sz001389':'广合科技',
|
|
289
|
+
'sz002179':'中航光电', 'sz002491':'通鼎互联', 'sh601231':'环旭电子', 'sz002156':'通富微电',
|
|
290
|
+
'sz300476':'胜宏科技', 'sh600406':'国电南瑞', 'sz000807':'云铝股份', 'sh600961':'株冶集团',
|
|
291
|
+
'sh605117':'德业股份', 'sh603271':'永杰新材', 'sh603129':'春风动力', 'sh603288':'海天味业',
|
|
292
|
+
'sh603277':'银都股份', 'sh603606':'东方电缆', 'sz002466':'天齐锂业', 'sz002460':'赣锋锂业',
|
|
293
|
+
'sz002240':'盛新锂能', 'sz002552':'宝鼎科技', 'sz002896':'中大力德', 'sz002080':'中材科技',
|
|
294
|
+
'sh600176':'中国巨石', 'sh603217':'元利科技', 'sh603078':'江化微', 'sz002643':'万润股份',
|
|
295
|
+
'sz002741':'光华科技', 'sz000630':'铜陵有色', 'sh600110':'诺德股份', 'sh601208':'东材科技',
|
|
296
|
+
'sz002636':'金安国纪', 'sh603186':'华正新材', 'sh600183':'生益科技', 'sz002384':'东山精密',
|
|
297
|
+
'sh600703':'三安光电', 'sz002475':'立讯精密'
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// "清单速选"下拉框:由 DEFAULT_RANK_LIST + STOCK_NAMES 生成,按名称拼音排序便于查找
|
|
301
|
+
function loadQuickPick() {
|
|
302
|
+
const sel = document.getElementById('quickPick');
|
|
303
|
+
if (!sel) return;
|
|
304
|
+
const sorted = [...DEFAULT_RANK_LIST].sort((a, b) =>
|
|
305
|
+
(STOCK_NAMES[a] || a).localeCompare(STOCK_NAMES[b] || b, 'zh-Hans-CN'));
|
|
306
|
+
const opts = [`<option value="">⚡ 清单速选(${DEFAULT_RANK_LIST.length}只),挑一只直接分析…</option>`];
|
|
307
|
+
for (const code of sorted) opts.push(`<option value="${code}">${STOCK_NAMES[code] || code}(${code})</option>`);
|
|
308
|
+
sel.innerHTML = opts.join('');
|
|
309
|
+
sel.onchange = () => { if (sel.value) { quickAnalyze(sel.value); sel.selectedIndex = 0; } };
|
|
310
|
+
}
|
|
311
|
+
loadQuickPick();
|
|
312
|
+
|
|
376
313
|
const rankBtn = document.getElementById('rankBtn');
|
|
377
314
|
const rankListInput = document.getElementById('rankListInput');
|
|
378
315
|
const rankPoolHint = document.getElementById('rankPoolHint');
|
|
316
|
+
const groupTabsEl = document.getElementById('groupTabs');
|
|
317
|
+
const saveGroupBtn = document.getElementById('saveGroupBtn');
|
|
318
|
+
const delGroupBtn = document.getElementById('delGroupBtn');
|
|
379
319
|
|
|
380
320
|
// 把输入框文本拆成代码/名称数组(逗号、空格、换行、中文逗号/顿号都当分隔符)
|
|
381
321
|
function parseRankInput(text) {
|
|
382
322
|
return (text || '').split(/[\s,,、;;]+/).map(s => s.trim()).filter(Boolean);
|
|
383
323
|
}
|
|
384
324
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
325
|
+
// ==================== 自定义分组 ====================
|
|
326
|
+
// 数据存浏览器 localStorage(rankGroups): [{name, codes:[]}]
|
|
327
|
+
// "默认清单"是特殊分组:内置 DEFAULT_RANK_LIST + 额外(沿用旧逻辑,不可删)
|
|
328
|
+
const DEFAULT_GROUP = '默认清单';
|
|
329
|
+
let rankGroups = []; // 用户自定义分组
|
|
330
|
+
let activeGroup = DEFAULT_GROUP;
|
|
331
|
+
|
|
332
|
+
function loadGroups() {
|
|
333
|
+
try { rankGroups = JSON.parse(localStorage.getItem('rankGroups') || '[]'); }
|
|
334
|
+
catch (e) { rankGroups = []; }
|
|
335
|
+
if (!Array.isArray(rankGroups)) rankGroups = [];
|
|
336
|
+
}
|
|
337
|
+
function saveGroups() { localStorage.setItem('rankGroups', JSON.stringify(rankGroups)); }
|
|
338
|
+
function findGroup(name) { return rankGroups.find(g => g.name === name); }
|
|
339
|
+
function upsertGroup(name, codes) {
|
|
340
|
+
const g = findGroup(name);
|
|
341
|
+
if (g) g.codes = codes; else rankGroups.push({ name, codes });
|
|
342
|
+
saveGroups();
|
|
343
|
+
}
|
|
344
|
+
// 分组名去掉会破坏属性/HTML 的字符
|
|
345
|
+
function cleanName(s) { return (s || '').replace(/['"<>]/g, '').trim(); }
|
|
346
|
+
|
|
347
|
+
// 分组标签:默认清单 + 各自定义组 + 新建
|
|
348
|
+
function renderGroupTabs() {
|
|
349
|
+
const chip = (label, name, active, kind) => {
|
|
350
|
+
const handler = kind === 'add' ? 'newGroup()' : `selectGroup('${name.replace(/'/g, "\\'")}')`;
|
|
351
|
+
const border = active ? '#b39dff' : (kind === 'add' ? '#3a5a3a' : '#2a3a4a');
|
|
352
|
+
const color = active ? '#d4c4ff' : (kind === 'add' ? '#7bd88f' : '#9fb4c8');
|
|
353
|
+
return `<span onclick="${handler}" style="cursor:pointer;padding:5px 12px;border-radius:14px;font-size:12px;border:1px solid ${border};background:${active ? '#5a3dbf33' : '#1a2530'};color:${color}">${label}</span>`;
|
|
354
|
+
};
|
|
355
|
+
let h = chip(`📋 默认清单(${DEFAULT_RANK_LIST.length})`, DEFAULT_GROUP, activeGroup === DEFAULT_GROUP, 'def');
|
|
356
|
+
h += rankGroups.map(g => chip(`${g.name}(${g.codes.length})`, g.name, activeGroup === g.name, 'grp')).join('');
|
|
357
|
+
h += chip('+ 新建分组', '', false, 'add');
|
|
358
|
+
groupTabsEl.innerHTML = h;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 切换分组:回填编辑框 + 调整按钮文案/可见性
|
|
362
|
+
function selectGroup(name) {
|
|
363
|
+
activeGroup = name;
|
|
364
|
+
if (name === DEFAULT_GROUP) {
|
|
365
|
+
const saved = localStorage.getItem('rankListCustom');
|
|
366
|
+
rankListInput.value = saved ? parseRankInput(saved).join(', ') : '';
|
|
367
|
+
rankListInput.placeholder = '默认清单已含,在此填要额外加入的代码/名称(逗号/空格/换行分隔)。留空则只排默认清单';
|
|
368
|
+
saveGroupBtn.textContent = '💾 另存为分组';
|
|
369
|
+
delGroupBtn.style.display = 'none';
|
|
370
|
+
} else {
|
|
371
|
+
const g = findGroup(name);
|
|
372
|
+
rankListInput.value = g ? g.codes.join(', ') : '';
|
|
373
|
+
rankListInput.placeholder = '本组的股票代码/名称(逗号/空格/换行分隔)。编辑后点"排名本组"或"保存修改"即生效';
|
|
374
|
+
saveGroupBtn.textContent = '💾 保存修改';
|
|
375
|
+
delGroupBtn.style.display = 'inline-block';
|
|
376
|
+
}
|
|
377
|
+
renderGroupTabs();
|
|
389
378
|
updateRankHint();
|
|
390
379
|
}
|
|
380
|
+
|
|
381
|
+
function newGroup() {
|
|
382
|
+
const name = cleanName(prompt('新分组名称(如:核心仓 / 短线观察 / 新能源备选):'));
|
|
383
|
+
if (!name) return;
|
|
384
|
+
if (name === DEFAULT_GROUP) { selectGroup(DEFAULT_GROUP); return; }
|
|
385
|
+
if (!findGroup(name)) { rankGroups.push({ name, codes: [] }); saveGroups(); }
|
|
386
|
+
selectGroup(name);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function saveCurrentGroup() {
|
|
390
|
+
const codes = parseRankInput(rankListInput.value);
|
|
391
|
+
if (activeGroup === DEFAULT_GROUP) {
|
|
392
|
+
// 默认清单 → 把(默认+额外)快照另存为一个可编辑的新分组
|
|
393
|
+
const name = cleanName(prompt('把当前清单另存为新分组,名称:'));
|
|
394
|
+
if (!name || name === DEFAULT_GROUP) return;
|
|
395
|
+
upsertGroup(name, [...new Set([...DEFAULT_RANK_LIST, ...codes])]);
|
|
396
|
+
selectGroup(name);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (!codes.length && !confirm('本组没有任何代码,确定保存为空组?')) return;
|
|
400
|
+
upsertGroup(activeGroup, codes);
|
|
401
|
+
renderGroupTabs();
|
|
402
|
+
updateRankHint();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function deleteCurrentGroup() {
|
|
406
|
+
if (activeGroup === DEFAULT_GROUP) return;
|
|
407
|
+
if (!confirm(`删除分组「${activeGroup}」?(不影响个股数据,可重新建)`)) return;
|
|
408
|
+
rankGroups = rankGroups.filter(g => g.name !== activeGroup);
|
|
409
|
+
saveGroups();
|
|
410
|
+
selectGroup(DEFAULT_GROUP);
|
|
411
|
+
}
|
|
412
|
+
|
|
391
413
|
function updateRankHint() {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
414
|
+
let count;
|
|
415
|
+
if (activeGroup === DEFAULT_GROUP) {
|
|
416
|
+
const extra = parseRankInput(rankListInput.value);
|
|
417
|
+
count = DEFAULT_RANK_LIST.length + extra.length;
|
|
418
|
+
rankPoolHint.textContent = extra.length
|
|
419
|
+
? `默认 ${DEFAULT_RANK_LIST.length} + 额外 ${extra.length} ≈ ${count} 只`
|
|
420
|
+
: `仅默认 ${DEFAULT_RANK_LIST.length} 只`;
|
|
421
|
+
} else {
|
|
422
|
+
count = parseRankInput(rankListInput.value).length;
|
|
423
|
+
rankPoolHint.textContent = `分组「${activeGroup}」共 ${count} 只`;
|
|
424
|
+
}
|
|
425
|
+
rankPoolHint.textContent += `(含回测,约需 ${Math.max(5, Math.round(count * 0.4))}-${count * 2} 秒)`;
|
|
397
426
|
}
|
|
427
|
+
|
|
398
428
|
function resetRankList() {
|
|
399
429
|
rankListInput.value = '';
|
|
400
|
-
localStorage.removeItem('rankListCustom');
|
|
430
|
+
if (activeGroup === DEFAULT_GROUP) localStorage.removeItem('rankListCustom');
|
|
401
431
|
updateRankHint();
|
|
402
432
|
}
|
|
433
|
+
|
|
403
434
|
rankListInput.addEventListener('input', updateRankHint);
|
|
404
|
-
|
|
435
|
+
loadGroups();
|
|
436
|
+
selectGroup(DEFAULT_GROUP); // 初始化:渲染标签 + 回填默认额外
|
|
405
437
|
|
|
406
438
|
// 保存上一次排名的渲染结果(仅前端内存,非接口缓存),用于点进个股后快速返回
|
|
407
439
|
let lastRankHTML = '';
|
|
408
440
|
let lastRankData = null; // 上次排名的原始数据,用于本地重新筛选(不重算)
|
|
409
441
|
let rankFilterStrict = false; // 是否只看"可博弈"(综合评分≥5 且 盈亏比>1)
|
|
442
|
+
let rankSectorFilter = null; // 当前板块筛选(null=全部),板块名取自估值接口的行业字段
|
|
443
|
+
let rankLaunchFilter = false; // 是否只看"启动在即"(潜伏突破)
|
|
444
|
+
|
|
445
|
+
// 视图缓存与返回:个股详情可返回上一视图;板块面板/板块排名结果均缓存,避免重复拉取/重算
|
|
446
|
+
let lastSectorsHTML = ''; // 热门板块面板渲染结果(缓存,可一键返回)
|
|
447
|
+
let lastSectorsData = null; // 热门板块原始数据
|
|
448
|
+
let lastSectorsAt = 0; // 上次取板块数据的时间戳(客户端缓存)
|
|
449
|
+
let backRef = null; // 当前个股详情来自哪个视图: 'rank' | 'sectors'
|
|
450
|
+
let rankBackToSectors = false; // 本次排名是否由"板块成分股"发起(决定排名页是否显示"返回热门板块")
|
|
451
|
+
const boardRankCache = {}; // 板块代码 -> 排名原始数据(同一板块再点不重算)
|
|
452
|
+
const SECTORS_TTL = 60 * 1000;
|
|
410
453
|
|
|
411
454
|
function toggleRankFilter() {
|
|
412
455
|
if (!lastRankData) return;
|
|
@@ -414,22 +457,182 @@
|
|
|
414
457
|
renderRank(lastRankData); // 用内存数据重渲染,零网络、零重算
|
|
415
458
|
}
|
|
416
459
|
|
|
417
|
-
function
|
|
418
|
-
if (!
|
|
460
|
+
function toggleLaunchFilter() {
|
|
461
|
+
if (!lastRankData) return;
|
|
462
|
+
rankLaunchFilter = !rankLaunchFilter;
|
|
463
|
+
renderRank(lastRankData);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function selectSector(name) {
|
|
467
|
+
if (!lastRankData) return;
|
|
468
|
+
rankSectorFilter = (rankSectorFilter === name) ? null : name; // 再点一次取消
|
|
469
|
+
renderRank(lastRankData); // 本地重渲染,零网络、零重算
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function showCachedHTML(html) {
|
|
473
|
+
if (!html) return;
|
|
419
474
|
errorDiv.classList.remove('active');
|
|
420
|
-
resultDiv.innerHTML =
|
|
475
|
+
resultDiv.innerHTML = html;
|
|
421
476
|
resultDiv.classList.add('active');
|
|
422
477
|
window.scrollTo(0, 0);
|
|
423
478
|
}
|
|
479
|
+
function backToRank() { showCachedHTML(lastRankHTML); }
|
|
480
|
+
function backToSectors() { showCachedHTML(lastSectorsHTML); }
|
|
481
|
+
|
|
482
|
+
// ==================== 今日热门板块 ====================
|
|
483
|
+
// force=false 时:60秒内直接复用上次结果(不重新拉取);点面板里的"刷新"才强制重取
|
|
484
|
+
async function doSectors(force = false) {
|
|
485
|
+
if (!force && lastSectorsHTML && Date.now() - lastSectorsAt < SECTORS_TTL) {
|
|
486
|
+
showCachedHTML(lastSectorsHTML); // 命中缓存,零网络
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const sectorBtn = document.getElementById('sectorBtn');
|
|
490
|
+
btn.disabled = true; rankBtn.disabled = true; sectorBtn.disabled = true;
|
|
491
|
+
errorDiv.classList.remove('active');
|
|
492
|
+
resultDiv.classList.remove('active');
|
|
493
|
+
loading.classList.add('active');
|
|
494
|
+
loadingText.textContent = '正在获取今日板块行情(行业 + 概念)...';
|
|
495
|
+
try {
|
|
496
|
+
const res = await fetch('/api/sectors');
|
|
497
|
+
const data = await res.json();
|
|
498
|
+
if (data.error) { showError(data.error); return; }
|
|
499
|
+
lastSectorsData = data;
|
|
500
|
+
lastSectorsAt = Date.now();
|
|
501
|
+
renderSectors(data);
|
|
502
|
+
} catch (e) {
|
|
503
|
+
showError('网络请求失败: ' + e.message);
|
|
504
|
+
} finally {
|
|
505
|
+
btn.disabled = false; rankBtn.disabled = false; sectorBtn.disabled = false;
|
|
506
|
+
loading.classList.remove('active');
|
|
507
|
+
loadingText.textContent = '正在获取数据并分析中...';
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function renderSectors(data) {
|
|
512
|
+
const pctColor = p => p == null ? '#888' : p > 0 ? '#f44336' : p < 0 ? '#26c281' : '#888';
|
|
513
|
+
const inflowColor = v => v == null ? '#888' : v > 0 ? '#f44336' : '#26c281';
|
|
514
|
+
const esc = s => (s || '').replace(/'/g, "\\'");
|
|
515
|
+
|
|
516
|
+
// 一张板块表(hot=领涨榜 / inflow=资金榜)
|
|
517
|
+
const boardTable = (list, mode) => {
|
|
518
|
+
let h = `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
519
|
+
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #2a3a4a">
|
|
520
|
+
<th style="padding:8px 6px">#</th>
|
|
521
|
+
<th style="padding:8px 6px">板块</th>
|
|
522
|
+
<th style="padding:8px 6px;text-align:right">涨跌幅</th>
|
|
523
|
+
<th style="padding:8px 6px;text-align:right" title="主力资金净流入">主力净流入</th>
|
|
524
|
+
<th style="padding:8px 6px;text-align:center" title="上涨/下跌家数">涨/跌</th>
|
|
525
|
+
<th style="padding:8px 6px">领涨股</th>
|
|
526
|
+
<th style="padding:8px 6px;text-align:center">操作</th>
|
|
527
|
+
</tr></thead><tbody>`;
|
|
528
|
+
list.forEach((b, i) => {
|
|
529
|
+
const lurk = mode === 'inflow' && b.lurking;
|
|
530
|
+
h += `<tr style="border-bottom:1px solid #1a2530;${lurk ? 'background:rgba(255,152,0,0.08);' : ''}">
|
|
531
|
+
<td style="padding:8px 6px;color:#666">${i + 1}</td>
|
|
532
|
+
<td style="padding:8px 6px;color:#fff;font-weight:bold">${b.name}${lurk ? ' <span title="主力净流入大但涨幅还小,疑似潜伏吸筹" style="font-size:11px;color:#ffb74d">潜伏</span>' : ''}</td>
|
|
533
|
+
<td style="padding:8px 6px;text-align:right;font-weight:bold;color:${pctColor(b.changePct)}">${b.changePct > 0 ? '+' : ''}${b.changePct != null ? b.changePct + '%' : '-'}</td>
|
|
534
|
+
<td style="padding:8px 6px;text-align:right;color:${inflowColor(b.netInflowYi)}">${b.netInflowYi != null ? (b.netInflowYi > 0 ? '+' : '') + b.netInflowYi + '亿' : '-'}</td>
|
|
535
|
+
<td style="padding:8px 6px;text-align:center;font-size:12px"><span style="color:#f44336">${b.upCount ?? '-'}</span><span style="color:#666">/</span><span style="color:#26c281">${b.downCount ?? '-'}</span></td>
|
|
536
|
+
<td style="padding:8px 6px;font-size:12px">${b.leadCode ? `<span onclick="quickAnalyze('${b.leadCode}')" style="cursor:pointer;color:#9fb4c8;border-bottom:1px dashed #3a4a5a">${b.leadName}</span> <span style="color:${pctColor(b.leadChangePct)}">${b.leadChangePct > 0 ? '+' : ''}${b.leadChangePct != null ? b.leadChangePct + '%' : ''}</span>` : (b.leadName || '-')}</td>
|
|
537
|
+
<td style="padding:8px 6px;text-align:center"><button onclick="rankBoard('${b.code}','${esc(b.name)}')" style="padding:4px 10px;border:1px solid #5a3dbf;border-radius:6px;background:transparent;color:#b39dff;font-size:12px;cursor:pointer">排名成分股</button></td>
|
|
538
|
+
</tr>`;
|
|
539
|
+
});
|
|
540
|
+
h += `</tbody></table></div>`;
|
|
541
|
+
return h;
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
const section = (title, sub, list, mode) => `<div style="margin-bottom:22px">
|
|
545
|
+
<div style="font-size:14px;font-weight:bold;color:#ff8a80;margin-bottom:2px">${title}</div>
|
|
546
|
+
<div style="font-size:11px;color:#777;margin-bottom:8px">${sub}</div>
|
|
547
|
+
${boardTable(list, mode)}
|
|
548
|
+
</div>`;
|
|
549
|
+
|
|
550
|
+
const updatedStr = data.updatedAt ? new Date(data.updatedAt).toLocaleTimeString('zh-CN', { hour12: false }) : '';
|
|
551
|
+
let html = `<div style="background:#1a2530;border:1px solid #2a3a4a;border-radius:10px;padding:16px 20px;margin-bottom:18px">
|
|
552
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
|
|
553
|
+
<span style="font-size:17px;font-weight:bold;color:#ff8a80">🔥 今日热门板块</span>
|
|
554
|
+
<span style="font-size:12px;color:#888">${updatedStr ? '更新于 ' + updatedStr + ' · ' : ''}<span onclick="doSectors(true)" style="cursor:pointer;color:#b39dff;border-bottom:1px dashed #5a3dbf">🔄 刷新</span></span>
|
|
555
|
+
</div>
|
|
556
|
+
<div style="font-size:12px;color:#888;line-height:1.7">
|
|
557
|
+
数据来自东方财富(板块涨幅 + 主力资金净流入),约 1 分钟缓存,重复点开不重新拉取。<br>
|
|
558
|
+
· <b style="color:#aaa">今日领涨</b>=涨幅榜(已经在涨的强势板块) · <b style="color:#aaa">资金净流入</b>=主力买入榜<br>
|
|
559
|
+
· <b style="color:#ffb74d">潜伏</b>=主力明显净流入但涨幅还小,资金先于价格进场,可能正在悄悄吸筹(更接近"刚准备启动")。<br>
|
|
560
|
+
点任一板块的「排名成分股」可把该板块成分股送入四维综合排名(取前30只,含回测)。
|
|
561
|
+
</div>
|
|
562
|
+
</div>`;
|
|
563
|
+
|
|
564
|
+
html += `<div style="font-size:15px;font-weight:bold;color:#b39dff;margin:6px 0 12px">📂 行业板块</div>`;
|
|
565
|
+
html += section('🔥 今日领涨', '涨幅居前的行业板块', data.industry.hot, 'hot');
|
|
566
|
+
html += section('💰 主力资金净流入', '主力净买入居前;标"潜伏"者涨幅尚小、疑似吸筹', data.industry.inflow, 'inflow');
|
|
567
|
+
html += `<div style="font-size:15px;font-weight:bold;color:#b39dff;margin:18px 0 12px">🧩 概念板块</div>`;
|
|
568
|
+
html += section('🔥 今日领涨', '涨幅居前的概念板块', data.concept.hot, 'hot');
|
|
569
|
+
html += section('💰 主力资金净流入', '主力净买入居前;标"潜伏"者涨幅尚小、疑似吸筹', data.concept.inflow, 'inflow');
|
|
570
|
+
|
|
571
|
+
html += `<div class="disclaimer">板块行情仅供参考,不构成投资建议。资金流向为当日盘面数据,次日未必延续。</div>`;
|
|
572
|
+
lastSectorsHTML = html; // 缓存,点进个股后可一键返回,无需重新拉取
|
|
573
|
+
backRef = 'sectors'; // 标记:个股详情从热门板块进入
|
|
574
|
+
resultDiv.innerHTML = html;
|
|
575
|
+
resultDiv.classList.add('active');
|
|
576
|
+
window.scrollTo(0, 0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// 把某板块成分股(取前N只)送入批量排名;同一板块已排过则直接复用,不重算
|
|
580
|
+
async function rankBoard(boardCode, boardName) {
|
|
581
|
+
if (boardRankCache[boardCode]) {
|
|
582
|
+
// 命中缓存:本地重渲染,零网络、零重算
|
|
583
|
+
rankSectorFilter = null; rankFilterStrict = false; rankLaunchFilter = false;
|
|
584
|
+
rankBackToSectors = true;
|
|
585
|
+
lastRankData = boardRankCache[boardCode];
|
|
586
|
+
renderRank(lastRankData);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
btn.disabled = true; rankBtn.disabled = true;
|
|
590
|
+
errorDiv.classList.remove('active');
|
|
591
|
+
resultDiv.classList.remove('active');
|
|
592
|
+
loading.classList.add('active');
|
|
593
|
+
loadingText.textContent = `正在获取「${boardName}」板块成分股...`;
|
|
594
|
+
let codes = [];
|
|
595
|
+
try {
|
|
596
|
+
const res = await fetch(`/api/sector_members?board=${encodeURIComponent(boardCode)}&limit=30`);
|
|
597
|
+
const data = await res.json();
|
|
598
|
+
if (data.error) { showError(data.error); return; }
|
|
599
|
+
codes = (data.members || []).map(m => m.code);
|
|
600
|
+
if (!codes.length) { showError(`未取到「${boardName}」的成分股`); return; }
|
|
601
|
+
} catch (e) {
|
|
602
|
+
showError('网络请求失败: ' + e.message); return;
|
|
603
|
+
} finally {
|
|
604
|
+
loading.classList.remove('active');
|
|
605
|
+
btn.disabled = false; rankBtn.disabled = false;
|
|
606
|
+
}
|
|
607
|
+
const data = await runRankFetch(codes, { fromBoard: true }); // 复用排名渲染
|
|
608
|
+
if (data) boardRankCache[boardCode] = data; // 缓存本板块排名,再次点击不重算
|
|
609
|
+
}
|
|
424
610
|
|
|
425
611
|
async function doRank() {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
612
|
+
let list;
|
|
613
|
+
if (activeGroup === DEFAULT_GROUP) {
|
|
614
|
+
// 默认清单:内置 + 额外(沿用旧逻辑,额外清单存本机)
|
|
615
|
+
const extra = parseRankInput(rankListInput.value);
|
|
616
|
+
list = [...DEFAULT_RANK_LIST, ...extra];
|
|
617
|
+
if (extra.length) localStorage.setItem('rankListCustom', extra.join(','));
|
|
618
|
+
else localStorage.removeItem('rankListCustom');
|
|
619
|
+
} else {
|
|
620
|
+
// 自定义分组:只排本组,并把编辑框内容自动存回该分组
|
|
621
|
+
list = parseRankInput(rankListInput.value);
|
|
622
|
+
if (!list.length) { showError(`分组「${activeGroup}」还没有股票,请先在编辑框里填代码/名称`); return; }
|
|
623
|
+
upsertGroup(activeGroup, list);
|
|
624
|
+
renderGroupTabs();
|
|
625
|
+
}
|
|
432
626
|
|
|
627
|
+
await runRankFetch(list);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// 对一组代码跑排名并渲染(默认清单/自定义分组/板块成分股都复用它)
|
|
631
|
+
// opts.fromBoard=true 时,排名页显示"返回热门板块";返回拿到的原始数据供调用方缓存
|
|
632
|
+
async function runRankFetch(list, opts = {}) {
|
|
633
|
+
if (!list || !list.length) { showError('没有可排名的股票'); return null; }
|
|
634
|
+
rankSectorFilter = null; rankFilterStrict = false; rankLaunchFilter = false; // 新一轮排名,重置筛选
|
|
635
|
+
rankBackToSectors = !!opts.fromBoard;
|
|
433
636
|
btn.disabled = true;
|
|
434
637
|
rankBtn.disabled = true;
|
|
435
638
|
errorDiv.classList.remove('active');
|
|
@@ -439,11 +642,13 @@
|
|
|
439
642
|
try {
|
|
440
643
|
const res = await fetch(`/api/rank?codes=${encodeURIComponent(list.join(','))}`);
|
|
441
644
|
const data = await res.json();
|
|
442
|
-
if (data.error) { showError(data.error); return; }
|
|
645
|
+
if (data.error) { showError(data.error); return null; }
|
|
443
646
|
lastRankData = data;
|
|
444
647
|
renderRank(data);
|
|
648
|
+
return data;
|
|
445
649
|
} catch (e) {
|
|
446
650
|
showError('网络请求失败: ' + e.message);
|
|
651
|
+
return null;
|
|
447
652
|
} finally {
|
|
448
653
|
btn.disabled = false;
|
|
449
654
|
rankBtn.disabled = false;
|
|
@@ -457,19 +662,32 @@
|
|
|
457
662
|
const ok = results.filter(r => !r.error);
|
|
458
663
|
const bad = results.filter(r => r.error);
|
|
459
664
|
|
|
665
|
+
// 板块名取自估值接口的行业字段(BOARD_NAME);台股/接口失败归为"其他"
|
|
666
|
+
const sectorOf = r => (r.valuation && r.valuation.board) ? r.valuation.board : '其他';
|
|
667
|
+
// 各板块计数,按数量从多到少排序,用于生成筛选标签
|
|
668
|
+
const sectorCounts = {};
|
|
669
|
+
ok.forEach(r => { const s = sectorOf(r); sectorCounts[s] = (sectorCounts[s] || 0) + 1; });
|
|
670
|
+
const sectors = Object.keys(sectorCounts).sort((a, b) => sectorCounts[b] - sectorCounts[a]);
|
|
671
|
+
// 选中某板块后,候选/筛选/表格都只在该板块内进行(=按板块划分)
|
|
672
|
+
const pool = rankSectorFilter ? ok.filter(r => sectorOf(r) === rankSectorFilter) : ok;
|
|
673
|
+
|
|
460
674
|
// 综合筛选:回测攻守兼备(盈利因子≥1.5 且 最大回撤≤25% 且 复利>0)
|
|
461
675
|
const isQualified = b => b && b.profitFactor >= 1.5 && b.maxDrawdown <= 25 && b.compoundReturn > 0;
|
|
462
|
-
const starCount =
|
|
676
|
+
const starCount = pool.filter(r => isQualified(r.backtest)).length;
|
|
463
677
|
|
|
464
678
|
const signalColor = (s, score) =>
|
|
465
679
|
score >= 10 ? '#ff4444' : score >= 5 ? '#ff7043' : score >= 1 ? '#ff9800'
|
|
466
680
|
: score >= -4 ? '#888' : score >= -9 ? '#26c281' : '#00cc66';
|
|
467
681
|
const rrColor = rr => rr >= 2 ? '#4caf50' : rr >= 1 ? '#ff9800' : '#f44336';
|
|
468
682
|
|
|
469
|
-
|
|
683
|
+
// 若本次排名由"板块成分股"发起,顶部给一个返回热门板块的入口(零网络)
|
|
684
|
+
let html = rankBackToSectors && lastSectorsHTML
|
|
685
|
+
? `<button onclick="backToSectors()" style="margin-bottom:14px;padding:8px 16px;border:1px solid #bf3d5a;border-radius:8px;background:transparent;color:#ff8a80;font-size:13px;cursor:pointer">← 返回热门板块</button>`
|
|
686
|
+
: '';
|
|
687
|
+
html += `<div style="background:#1a2530;border:1px solid #2a3a4a;border-radius:10px;padding:18px 20px;margin-bottom:18px">
|
|
470
688
|
<div style="font-size:17px;font-weight:bold;color:#b39dff;margin-bottom:6px">四维综合排名(均衡型)</div>
|
|
471
689
|
<div style="font-size:12px;color:#888;line-height:1.7">
|
|
472
|
-
综合分 = <b style="color:#aaa">技术面 / 估值 / 基本面 / 消息面</b> 四维等权平均(各 0~100,越高越好),按综合分从高到低排序,共 ${ok.length} 只有效${bad.length ? `(${bad.length} 只数据不足/失败)` : ''}。
|
|
690
|
+
综合分 = <b style="color:#aaa">技术面 / 估值 / 基本面 / 消息面</b> 四维等权平均(各 0~100,越高越好),按综合分从高到低排序,共 ${ok.length} 只有效${bad.length ? `(${bad.length} 只数据不足/失败)` : ''}${rankSectorFilter ? `,当前仅看 <b style="color:#b39dff">${rankSectorFilter}</b> 板块 ${pool.length} 只` : ''}。
|
|
473
691
|
大盘环境:${(marketEnv && marketEnv.signals ? marketEnv.signals.join(' | ') : '-')}<br>
|
|
474
692
|
· <b style="color:#aaa">技术</b>=评分标准化(择时) · <b style="color:#aaa">估值</b>=PE/PB历史分位 · <b style="color:#aaa">基本面</b>=ROE/增速/负债 · <b style="color:#aaa">消息</b>=公告关键词(利好+/利空−)<br>
|
|
475
693
|
<span style="color:#ffd54f">⭐ = 回测攻守兼备(盈利因子≥1.5 且 最大回撤≤25% 且 复利>0),本次 ${starCount} 只,已高亮。</span><br>
|
|
@@ -493,7 +711,7 @@
|
|
|
493
711
|
&& r.totalScore >= 1 // 当前技术面=买入侧(谨慎买入及以上)
|
|
494
712
|
&& isQualified(r.backtest); // 回测攻守兼备 ⭐
|
|
495
713
|
};
|
|
496
|
-
const candidates =
|
|
714
|
+
const candidates = pool.filter(isBuyCandidate).slice(0, 3);
|
|
497
715
|
const medals = ['🥇', '🥈', '🥉'];
|
|
498
716
|
const condLine = '条件:综合≥55 · 基本面≥45 · 估值≥40 · 消息面无明显利空 · 当前技术=买入侧 · 回测⭐';
|
|
499
717
|
html += `<div style="margin-bottom:22px">
|
|
@@ -527,20 +745,38 @@
|
|
|
527
745
|
}
|
|
528
746
|
html += `<div style="font-size:11px;color:#ff8a80;margin-top:8px">注:这是按规则筛出的"候选",仍不含消息面;买前自行查公告、定仓位、设止损。</div></div>`;
|
|
529
747
|
|
|
748
|
+
// 🧩 板块划分:点击某板块只看该板块,再点一次取消;数字为该板块有效只数
|
|
749
|
+
const sectorChip = (label, name, count, active) =>
|
|
750
|
+
`<span onclick="selectSector(${name === null ? 'null' : `'${name.replace(/'/g, "\\'")}'`})" style="cursor:pointer;padding:5px 12px;border-radius:14px;font-size:12px;border:1px solid ${active ? '#b39dff' : '#2a3a4a'};background:${active ? '#5a3dbf33' : '#1a2530'};color:${active ? '#d4c4ff' : '#9fb4c8'}">${label}${count != null ? ` <b style="color:${active ? '#fff' : '#777'}">${count}</b>` : ''}</span>`;
|
|
751
|
+
html += `<div style="margin-bottom:12px">
|
|
752
|
+
<div style="font-size:12px;color:#888;margin-bottom:8px">🧩 按板块划分(板块来自行业分类,点击只看该板块,再点取消)</div>
|
|
753
|
+
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
|
754
|
+
${sectorChip('全部', null, ok.length, !rankSectorFilter)}
|
|
755
|
+
${sectors.map(s => sectorChip(s, s, sectorCounts[s], rankSectorFilter === s)).join('')}
|
|
756
|
+
</div>
|
|
757
|
+
</div>`;
|
|
758
|
+
|
|
530
759
|
// 🎯 可博弈筛选:技术综合评分≥5 且 盈亏比>1(用户自定义条件)
|
|
531
760
|
const passStrict = r => r.totalScore >= 5 && r.riskRewardRatio != null && r.riskRewardRatio > 1;
|
|
532
|
-
const strictCount =
|
|
761
|
+
const strictCount = pool.filter(passStrict).length;
|
|
762
|
+
// 🚀 启动在即:潜伏横盘 + 刚放量突破平台
|
|
763
|
+
const passLaunch = r => r.launch && r.launch.isLaunching;
|
|
764
|
+
const launchCount = pool.filter(passLaunch).length;
|
|
533
765
|
html += `<div style="margin-bottom:12px;display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
534
766
|
<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
767
|
${rankFilterStrict ? '✓ 已筛选:可博弈(评分≥5 且 盈亏比>1) · 点此显示全部' : '🎯 只看可博弈(技术评分≥5 且 盈亏比>1)'}
|
|
536
768
|
</button>
|
|
537
|
-
<
|
|
769
|
+
<button onclick="toggleLaunchFilter()" style="padding:8px 16px;border:1px solid ${rankLaunchFilter ? '#ff9800' : '#5a3dbf'};border-radius:8px;background:${rankLaunchFilter ? '#ff980022' : 'transparent'};color:${rankLaunchFilter ? '#ffb74d' : '#b39dff'};font-size:13px;cursor:pointer">
|
|
770
|
+
${rankLaunchFilter ? '✓ 已筛选:启动在即 · 点此显示全部' : '🚀 只看启动在即(潜伏突破)'}
|
|
771
|
+
</button>
|
|
772
|
+
<span style="font-size:12px;color:#888">可博弈 <b style="color:${strictCount ? '#4caf50' : '#888'}">${strictCount}</b> · 启动在即 <b style="color:${launchCount ? '#ffb74d' : '#888'}">${launchCount}</b></span>
|
|
538
773
|
</div>`;
|
|
539
774
|
|
|
540
775
|
html += `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">
|
|
541
776
|
<thead><tr style="color:#888;text-align:left;border-bottom:1px solid #2a3a4a">
|
|
542
777
|
<th style="padding:10px 6px">#</th>
|
|
543
778
|
<th style="padding:10px 6px">名称</th>
|
|
779
|
+
<th style="padding:10px 6px" title="行业板块(来自行业分类)">板块</th>
|
|
544
780
|
<th style="padding:10px 6px;text-align:right">现价</th>
|
|
545
781
|
<th style="padding:10px 6px;text-align:right">涨跌</th>
|
|
546
782
|
<th style="padding:10px 6px;text-align:center" title="技术/估值/基本面 三维等权平均">综合分</th>
|
|
@@ -554,8 +790,9 @@
|
|
|
554
790
|
</tr></thead><tbody>`;
|
|
555
791
|
|
|
556
792
|
let shown = 0;
|
|
557
|
-
|
|
793
|
+
pool.forEach((r, i) => {
|
|
558
794
|
if (rankFilterStrict && !passStrict(r)) return; // 筛选模式:跳过不达标的,保留原排名号
|
|
795
|
+
if (rankLaunchFilter && !passLaunch(r)) return;
|
|
559
796
|
shown++;
|
|
560
797
|
const dir = r.changePct > 0 ? 'up' : r.changePct < 0 ? 'down' : 'flat';
|
|
561
798
|
const sc = signalColor(r.signal, r.totalScore);
|
|
@@ -570,7 +807,8 @@
|
|
|
570
807
|
: `<td style="padding:8px 6px;text-align:center;color:#666;font-size:12px">无信号</td>`;
|
|
571
808
|
html += `<tr style="border-bottom:1px solid #1a2530;cursor:pointer;${rowBg}" onclick="quickAnalyze('${r.code}')" title="${qualified ? '回测攻守兼备 · ' : ''}点击查看完整分析">
|
|
572
809
|
<td style="padding:8px 6px;color:${i < 3 ? '#ffd54f' : '#666'};font-weight:${i < 3 ? 'bold' : 'normal'}">${i + 1}</td>
|
|
573
|
-
<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>
|
|
810
|
+
<td style="padding:8px 6px">${qualified ? '<span title="回测攻守兼备">⭐</span> ' : ''}${r.launch && r.launch.isLaunching ? `<span title="启动在即:${(r.launch.signals || []).join(' / ')}">🚀</span> ` : ''}<span style="color:#fff;font-weight:bold">${r.name}</span> <span style="color:#666;font-size:11px">${r.code}</span></td>
|
|
811
|
+
<td style="padding:8px 6px"><span onclick="event.stopPropagation();selectSector('${sectorOf(r).replace(/'/g, "\\'")}')" title="点击只看该板块" style="cursor:pointer;font-size:12px;color:#9fb4c8;border-bottom:1px dashed #3a4a5a">${sectorOf(r)}</span></td>
|
|
574
812
|
<td style="padding:8px 6px;text-align:right;color:#ddd">${(r.price != null ? r.price.toFixed(2) : '-')}</td>
|
|
575
813
|
<td style="padding:8px 6px;text-align:right" class="${dir}">${r.changePct > 0 ? '+' : ''}${r.changePct}%</td>
|
|
576
814
|
<td style="padding:8px 6px;text-align:center"><span style="font-size:18px;font-weight:bold;color:${dimColor(r.composite)}">${r.composite}</span></td>
|
|
@@ -583,8 +821,11 @@
|
|
|
583
821
|
${btCell}
|
|
584
822
|
</tr>`;
|
|
585
823
|
});
|
|
586
|
-
if (
|
|
587
|
-
|
|
824
|
+
if (shown === 0 && (rankFilterStrict || rankLaunchFilter)) {
|
|
825
|
+
const why = rankLaunchFilter && !rankFilterStrict
|
|
826
|
+
? '当前股票池里没有"潜伏横盘 + 刚放量突破平台"的标的 —— 多数要么还在趴着没动、要么早已启动甚至高位。'
|
|
827
|
+
: '没有股票同时满足所选筛选条件 —— 当前多数标的要么动能不足、要么上方空间不够,空仓等待更稳妥。';
|
|
828
|
+
html += `<tr><td colspan="13" style="padding:20px;text-align:center;color:#ff9800;font-size:13px">${why}</td></tr>`;
|
|
588
829
|
}
|
|
589
830
|
html += `</tbody></table></div>`;
|
|
590
831
|
|
|
@@ -600,6 +841,7 @@
|
|
|
600
841
|
</div>`;
|
|
601
842
|
|
|
602
843
|
lastRankHTML = html; // 存起来,点进个股后可一键返回,无需重算
|
|
844
|
+
backRef = 'rank'; // 标记:个股详情从排名进入
|
|
603
845
|
resultDiv.innerHTML = html;
|
|
604
846
|
resultDiv.classList.add('active');
|
|
605
847
|
}
|
|
@@ -657,8 +899,10 @@
|
|
|
657
899
|
|
|
658
900
|
let html = '';
|
|
659
901
|
|
|
660
|
-
//
|
|
661
|
-
if (
|
|
902
|
+
// 若从排名/热门板块点进来的,显示一键返回上一视图(直接复用已渲染结果,不重新计算)
|
|
903
|
+
if (backRef === 'sectors' && lastSectorsHTML) {
|
|
904
|
+
html += `<button onclick="backToSectors()" style="margin-bottom:14px;padding:8px 16px;border:1px solid #bf3d5a;border-radius:8px;background:transparent;color:#ff8a80;font-size:13px;cursor:pointer">← 返回热门板块</button>`;
|
|
905
|
+
} else if (lastRankHTML) {
|
|
662
906
|
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>`;
|
|
663
907
|
}
|
|
664
908
|
|
|
@@ -711,8 +955,6 @@
|
|
|
711
955
|
<div style="font-size:11px;color:#888">净盈 / 净亏</div>
|
|
712
956
|
</div>
|
|
713
957
|
</div>` : `<div style="padding:12px;text-align:center;color:#888;font-size:13px;background:#141e28;border-radius:6px;margin-bottom:12px">回测周期内无做多入场信号</div>`}
|
|
714
|
-
<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>
|
|
715
|
-
<span style="font-size:11px;color:#666;margin-left:10px">加入后可在顶部快速访问</span>
|
|
716
958
|
</div>`;
|
|
717
959
|
}
|
|
718
960
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kamuira/stock-analyzer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"fundamentals.js",
|
|
52
52
|
"news.js",
|
|
53
53
|
"http-util.js",
|
|
54
|
+
"screener.js",
|
|
55
|
+
"sectors.js",
|
|
54
56
|
"index.html",
|
|
55
57
|
"README.md"
|
|
56
58
|
],
|
package/screener.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 形态筛选 — "潜伏启动"检测
|
|
3
|
+
*
|
|
4
|
+
* 目标:识别"长期横盘缩量蓄势、近期刚放量突破平台"的个股(即将/刚启动)。
|
|
5
|
+
* 思路(基台突破法):
|
|
6
|
+
* 1) 基台:取最近 55~5 天为横盘观察窗(排除最近5天的启动段)
|
|
7
|
+
* 2) 横盘特征:基台振幅窄 + 走势平缓(无单边趋势)
|
|
8
|
+
* 3) 启动迹象:现价突破/逼近基台上沿 + 近端放量 + 均线转多
|
|
9
|
+
* 返回 0~100 的"启动分"与可读信号;isLaunching=典型"启动在即"。
|
|
10
|
+
*
|
|
11
|
+
* 纯计算,输入为已拉取的 K 线(复用排名/分析已有数据,零额外请求)。
|
|
12
|
+
*/
|
|
13
|
+
const { SMA } = require('./indicators');
|
|
14
|
+
|
|
15
|
+
function sum(arr) { return arr.reduce((a, b) => a + b, 0); }
|
|
16
|
+
|
|
17
|
+
function detectLaunch(klines) {
|
|
18
|
+
const empty = { score: 0, isLaunching: false, stage: null, signals: ['数据不足'] };
|
|
19
|
+
if (!klines || klines.length < 70) return empty;
|
|
20
|
+
|
|
21
|
+
const closes = klines.map(k => k.close);
|
|
22
|
+
const highs = klines.map(k => k.high);
|
|
23
|
+
const lows = klines.map(k => k.low);
|
|
24
|
+
const vols = klines.map(k => k.volume);
|
|
25
|
+
const last = closes.length - 1;
|
|
26
|
+
const price = closes[last];
|
|
27
|
+
|
|
28
|
+
// 基台窗口:最近 55~5 天(排除最近 5 天,那是"启动段")
|
|
29
|
+
const BASE_START = last - 55, BASE_END = last - 5;
|
|
30
|
+
if (BASE_START < 0) return empty;
|
|
31
|
+
const baseHighs = highs.slice(BASE_START, BASE_END + 1);
|
|
32
|
+
const baseLows = lows.slice(BASE_START, BASE_END + 1);
|
|
33
|
+
const baseHigh = Math.max(...baseHighs);
|
|
34
|
+
const baseLow = Math.min(...baseLows);
|
|
35
|
+
const baseVolAvg = sum(vols.slice(BASE_START, BASE_END + 1)) / (BASE_END - BASE_START + 1);
|
|
36
|
+
if (!baseLow || !baseVolAvg) return empty;
|
|
37
|
+
|
|
38
|
+
const rangePct = (baseHigh - baseLow) / baseLow * 100; // 基台振幅
|
|
39
|
+
const drift = (closes[BASE_END] - closes[BASE_START]) / closes[BASE_START] * 100; // 基台漂移
|
|
40
|
+
const vol5 = sum(vols.slice(-5)) / 5;
|
|
41
|
+
const volIgnite = vol5 / baseVolAvg; // 近5日量能 / 基台均量
|
|
42
|
+
const breakoutPct = (price - baseHigh) / baseHigh * 100; // 现价相对基台上沿
|
|
43
|
+
const ma5 = SMA(closes, 5), ma20 = SMA(closes, 20);
|
|
44
|
+
|
|
45
|
+
const signals = [];
|
|
46
|
+
let score = 0;
|
|
47
|
+
|
|
48
|
+
// 1) 横盘够窄(蓄势充分)
|
|
49
|
+
const tightBase = rangePct <= 28;
|
|
50
|
+
if (tightBase) { score += 30; signals.push(`基台横盘约${BASE_END - BASE_START + 1}天,振幅仅${rangePct.toFixed(0)}%(蓄势充分)`); }
|
|
51
|
+
else if (rangePct <= 40) { score += 15; signals.push(`基台振幅${rangePct.toFixed(0)}%(偏宽)`); }
|
|
52
|
+
else signals.push(`基台振幅${rangePct.toFixed(0)}%(过宽,非典型横盘)`);
|
|
53
|
+
|
|
54
|
+
// 2) 基台平缓(无单边趋势)
|
|
55
|
+
if (Math.abs(drift) <= 12) { score += 15; signals.push('基台走势平缓,无单边趋势'); }
|
|
56
|
+
else signals.push(`基台已有${drift > 0 ? '上行' : '下行'}倾向(${drift.toFixed(0)}%)`);
|
|
57
|
+
|
|
58
|
+
// 3) 突破/逼近基台上沿
|
|
59
|
+
let stage = null;
|
|
60
|
+
if (breakoutPct >= 0 && breakoutPct <= 12) { score += 25; stage = '突破启动'; signals.push(`已突破基台上沿(+${breakoutPct.toFixed(1)}%),处启动初段`); }
|
|
61
|
+
else if (breakoutPct > -3 && breakoutPct < 0) { score += 18; stage = '临界待发'; signals.push(`逼近基台上沿(距突破${(-breakoutPct).toFixed(1)}%)`); }
|
|
62
|
+
else if (breakoutPct > 12) { score += 5; stage = '启动偏后'; signals.push(`已突破+${breakoutPct.toFixed(1)}%,启动偏后,追高谨慎`); }
|
|
63
|
+
else { stage = '基台内'; signals.push('仍在基台内运行,未见突破'); }
|
|
64
|
+
|
|
65
|
+
// 4) 放量启动
|
|
66
|
+
if (volIgnite >= 2) { score += 20; signals.push(`放量明显(近5日量能为基台均量${volIgnite.toFixed(1)}倍),资金进场`); }
|
|
67
|
+
else if (volIgnite >= 1.5) { score += 12; signals.push(`温和放量(${volIgnite.toFixed(1)}倍)`); }
|
|
68
|
+
else if (volIgnite < 0.9) signals.push(`近端缩量(${volIgnite.toFixed(1)}倍),动能不足`);
|
|
69
|
+
|
|
70
|
+
// 5) 均线转多
|
|
71
|
+
if (ma5[last] > ma20[last] && price > ma20[last]) { score += 10; signals.push('短均线上穿中均线且站上MA20(多头转向)'); }
|
|
72
|
+
|
|
73
|
+
score = Math.max(0, Math.min(100, score));
|
|
74
|
+
// 典型"启动在即":横盘够窄 + 总分够高 + 处突破/临界 + 确有放量
|
|
75
|
+
const isLaunching = tightBase && score >= 65 && (stage === '突破启动' || stage === '临界待发') && volIgnite >= 1.5;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
score, isLaunching, stage,
|
|
79
|
+
rangePct: +rangePct.toFixed(1),
|
|
80
|
+
breakoutPct: +breakoutPct.toFixed(1),
|
|
81
|
+
volIgnite: +volIgnite.toFixed(2),
|
|
82
|
+
signals,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { detectLaunch };
|
package/sectors.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 板块模块 — 数据来自东方财富 push2 行情接口
|
|
3
|
+
*
|
|
4
|
+
* 提供:
|
|
5
|
+
* - fetchHotSectors(): 行业+概念板块榜,给出"今日领涨"和"资金潜伏"两个视角
|
|
6
|
+
* - fetchBoardMembers(boardCode, limit): 某板块的成分股代码(供批量排名)
|
|
7
|
+
*
|
|
8
|
+
* 说明:push2 接口对"短时间高频请求"会返回空,故板块榜做 60 秒缓存、成分股做 3 分钟缓存。
|
|
9
|
+
* 字段含义: f3=涨跌幅% f12=代码 f13=市场(1沪/0深) f14=名称 f62=主力净流入(元)
|
|
10
|
+
* f104=上涨家数 f105=下跌家数 f128=领涨股名 f140=领涨股代码 f136=领涨股涨幅%
|
|
11
|
+
*/
|
|
12
|
+
const { getJSON } = require('./http-util');
|
|
13
|
+
const { curlText } = require('./analyze');
|
|
14
|
+
|
|
15
|
+
// push2.eastmoney.com 对 Node 的 TLS 指纹较敏感(国内直连常 socket reset),且国内访问可能需走代理。
|
|
16
|
+
// 故优先用系统 curl(指纹能过 + 自动读 https_proxy),失败再回退 Node https。
|
|
17
|
+
const REFERER = 'Referer: https://quote.eastmoney.com/';
|
|
18
|
+
const BOARD_FIELDS = 'f12,f13,f14,f3,f62,f104,f105,f128,f136,f140,f141';
|
|
19
|
+
|
|
20
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
const isValid = j => j && j.data && j.data.diff && j.data.diff.length;
|
|
23
|
+
|
|
24
|
+
// 依次尝试多种取数通道,返回首个有效结果(push2 境内源 + 反爬指纹敏感)
|
|
25
|
+
async function fetchOnce(url) {
|
|
26
|
+
// 1) curl 直连(不走代理):境内源最稳,且 curl 指纹能过 push2 反爬
|
|
27
|
+
try { const j = JSON.parse(await curlText(url, [REFERER], { noProxy: true })); if (isValid(j)) return j; } catch (e) { /* 下一通道 */ }
|
|
28
|
+
// 2) curl 走代理(若直连不通而代理可达)
|
|
29
|
+
try { const j = JSON.parse(await curlText(url, [REFERER])); if (isValid(j)) return j; } catch (e) { /* 下一通道 */ }
|
|
30
|
+
// 3) Node https 兜底(无 curl 时)
|
|
31
|
+
try { const j = await getJSON(url, { Referer: 'https://quote.eastmoney.com/' }); if (isValid(j)) return j; } catch (e) { /* 失败 */ }
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 失败/空响应退避重试,最多 2 轮(每轮内部已多通道尝试)
|
|
36
|
+
async function getJSONRetry(url, attempts = 2) {
|
|
37
|
+
for (let i = 0; i < attempts; i++) {
|
|
38
|
+
const j = await fetchOnce(url);
|
|
39
|
+
if (j) return j;
|
|
40
|
+
if (i < attempts - 1) await sleep(800 * (i + 1));
|
|
41
|
+
}
|
|
42
|
+
throw new Error('上游连续无响应(可能被限流或网络不通)');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 简单内存缓存
|
|
46
|
+
const cache = { boards: { ts: 0, data: null }, members: new Map() };
|
|
47
|
+
const BOARDS_TTL = 60 * 1000;
|
|
48
|
+
const MEMBERS_TTL = 3 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
function marketPrefix(f13) { return f13 === 1 ? 'sh' : 'sz'; }
|
|
51
|
+
|
|
52
|
+
// 拉一类板块的全部条目(行业 t:2 / 概念 t:3),按涨幅降序
|
|
53
|
+
async function fetchBoardList(type) {
|
|
54
|
+
const fs = `m:90+t:${type}`;
|
|
55
|
+
const url = 'https://push2.eastmoney.com/api/qt/clist/get'
|
|
56
|
+
+ `?pn=1&pz=500&po=1&np=1&fltt=2&invt=2&fid=f3&fs=${fs}&fields=${BOARD_FIELDS}`;
|
|
57
|
+
const j = await getJSONRetry(url);
|
|
58
|
+
const diff = j && j.data && j.data.diff;
|
|
59
|
+
if (!diff || !diff.length) return [];
|
|
60
|
+
return diff.map(d => ({
|
|
61
|
+
code: d.f12,
|
|
62
|
+
name: d.f14,
|
|
63
|
+
changePct: typeof d.f3 === 'number' ? d.f3 : null,
|
|
64
|
+
netInflow: typeof d.f62 === 'number' ? d.f62 : null, // 元
|
|
65
|
+
upCount: d.f104, downCount: d.f105,
|
|
66
|
+
leadName: d.f128,
|
|
67
|
+
leadCode: d.f128 && d.f140 ? marketPrefix(d.f141) + d.f140 : null,
|
|
68
|
+
leadChangePct: typeof d.f136 === 'number' ? d.f136 : null,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 今日热门板块:行业 + 概念,各给"领涨榜"和"资金净流入榜"。
|
|
74
|
+
* 资金榜里把"净流入大但涨幅还不大"的标为"潜伏吸筹"(lurking)。
|
|
75
|
+
*/
|
|
76
|
+
async function fetchHotSectors() {
|
|
77
|
+
if (cache.boards.data && Date.now() - cache.boards.ts < BOARDS_TTL) return cache.boards.data;
|
|
78
|
+
|
|
79
|
+
const [industry, concept] = await Promise.all([fetchBoardList(2), fetchBoardList(3)]);
|
|
80
|
+
|
|
81
|
+
const yi = 1e8; // 转成"亿元"
|
|
82
|
+
const annotate = list => list.map(b => ({
|
|
83
|
+
...b,
|
|
84
|
+
netInflowYi: b.netInflow != null ? +(b.netInflow / yi).toFixed(2) : null,
|
|
85
|
+
// 潜伏吸筹:主力明显净流入(≥0.5亿) 但涨幅还温和(<2.5%) —— 资金先于价格进场
|
|
86
|
+
lurking: b.netInflow != null && b.changePct != null && b.netInflow >= 0.5 * yi && b.changePct < 2.5,
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const byChange = list => [...list].sort((a, b) => (b.changePct ?? -99) - (a.changePct ?? -99));
|
|
90
|
+
const byInflow = list => [...list].sort((a, b) => (b.netInflow ?? -9e18) - (a.netInflow ?? -9e18));
|
|
91
|
+
|
|
92
|
+
const ind = annotate(industry), con = annotate(concept);
|
|
93
|
+
const data = {
|
|
94
|
+
updatedAt: new Date().toISOString(),
|
|
95
|
+
industry: { hot: byChange(ind).slice(0, 12), inflow: byInflow(ind).slice(0, 12) },
|
|
96
|
+
concept: { hot: byChange(con).slice(0, 12), inflow: byInflow(con).slice(0, 12) },
|
|
97
|
+
};
|
|
98
|
+
cache.boards = { ts: Date.now(), data };
|
|
99
|
+
return data;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 某板块成分股(默认按涨幅),返回 [{code,name,changePct}],code 形如 sh600000 */
|
|
103
|
+
async function fetchBoardMembers(boardCode, limit = 40) {
|
|
104
|
+
if (!/^BK\d{4,}$/i.test(boardCode)) throw new Error('板块代码非法');
|
|
105
|
+
const hit = cache.members.get(boardCode);
|
|
106
|
+
if (hit && Date.now() - hit.ts < MEMBERS_TTL) return hit.data.slice(0, limit);
|
|
107
|
+
|
|
108
|
+
const url = 'https://push2.eastmoney.com/api/qt/clist/get'
|
|
109
|
+
+ `?pn=1&pz=200&po=1&np=1&fltt=2&invt=2&fid=f3&fs=b:${boardCode}&fields=f12,f13,f14,f3`;
|
|
110
|
+
const j = await getJSONRetry(url);
|
|
111
|
+
const diff = j && j.data && j.data.diff;
|
|
112
|
+
if (!diff || !diff.length) return [];
|
|
113
|
+
const data = diff.map(d => ({ code: marketPrefix(d.f13) + d.f12, name: d.f14, changePct: d.f3 }));
|
|
114
|
+
cache.members.set(boardCode, { ts: Date.now(), data });
|
|
115
|
+
return data.slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { fetchHotSectors, fetchBoardMembers };
|
package/server.js
CHANGED
|
@@ -21,6 +21,8 @@ const { backtest: runBacktest } = require('./backtest');
|
|
|
21
21
|
const { fetchValuation, scoreValuation } = require('./valuation');
|
|
22
22
|
const { fetchFundamentals, scoreFundamentals } = require('./fundamentals');
|
|
23
23
|
const { fetchNewsCached, scoreNews } = require('./news');
|
|
24
|
+
const { detectLaunch } = require('./screener');
|
|
25
|
+
const { fetchHotSectors, fetchBoardMembers } = require('./sectors');
|
|
24
26
|
|
|
25
27
|
const PORT = 3000;
|
|
26
28
|
const HOST = '127.0.0.1';
|
|
@@ -165,6 +167,9 @@ async function rankStocks(inputs) {
|
|
|
165
167
|
const present = [techDim, val.score, fund.score, news.score].filter(s => s != null);
|
|
166
168
|
const composite = present.length ? Math.round(present.reduce((x, y) => x + y, 0) / present.length) : techDim;
|
|
167
169
|
|
|
170
|
+
// 潜伏启动检测(复用已拉的K线,零额外请求)
|
|
171
|
+
const launch = detectLaunch(klines);
|
|
172
|
+
|
|
168
173
|
return {
|
|
169
174
|
code, name: rt.name || code,
|
|
170
175
|
price: rt.price, changePct: rt.changePct,
|
|
@@ -177,6 +182,7 @@ async function rankStocks(inputs) {
|
|
|
177
182
|
backtest: bt,
|
|
178
183
|
composite,
|
|
179
184
|
dims,
|
|
185
|
+
launch: { score: launch.score, isLaunching: launch.isLaunching, stage: launch.stage, volIgnite: launch.volIgnite, rangePct: launch.rangePct, signals: launch.signals },
|
|
180
186
|
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
187
|
fundamental: { score: fund.score, label: fund.label || null, roe: fund.roe, revenueYoY: fund.revenueYoY, profitYoY: fund.profitYoY, reportDate: fund.reportDate, signals: fund.signals },
|
|
182
188
|
news: { score: news.score, label: news.label || null, hitCount: news.hitCount || 0, signals: news.signals },
|
|
@@ -324,6 +330,39 @@ const server = http.createServer(async (req, res) => {
|
|
|
324
330
|
return;
|
|
325
331
|
}
|
|
326
332
|
|
|
333
|
+
// API: 今日热门板块(行业+概念,领涨榜 + 资金潜伏榜)
|
|
334
|
+
if (url.pathname === '/api/sectors') {
|
|
335
|
+
try {
|
|
336
|
+
const data = await fetchHotSectors();
|
|
337
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
338
|
+
res.end(JSON.stringify(data));
|
|
339
|
+
} catch (e) {
|
|
340
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
341
|
+
res.end(JSON.stringify({ error: `板块数据获取失败: ${e.message}` }));
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// API: 某板块成分股代码(供前端送入批量排名)
|
|
347
|
+
if (url.pathname === '/api/sector_members') {
|
|
348
|
+
const board = url.searchParams.get('board');
|
|
349
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit')) || 30, 60);
|
|
350
|
+
if (!board) {
|
|
351
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
352
|
+
res.end(JSON.stringify({ error: '请提供板块代码(board=BKxxxx)' }));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const members = await fetchBoardMembers(board, limit);
|
|
357
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
358
|
+
res.end(JSON.stringify({ board, count: members.length, members }));
|
|
359
|
+
} catch (e) {
|
|
360
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
361
|
+
res.end(JSON.stringify({ error: `成分股获取失败: ${e.message}` }));
|
|
362
|
+
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
327
366
|
// API: 分析股票
|
|
328
367
|
if (url.pathname === '/api/analyze') {
|
|
329
368
|
const input = url.searchParams.get('code');
|