candidate-pool 3.0.0 → 3.0.2
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/CHANGELOG.md +45 -0
- package/build_universe.js +7 -1
- package/db/clients.js +9 -3
- package/db/persist.js +5 -3
- package/db/write_live.js +2 -1
- package/eod/data/preopen-2026-10-22.json +1128 -195
- package/eod/lib/http.js +56 -53
- package/eod/lib/preopen_store.js +11 -2
- package/eod/preopen_screener.js +20 -5
- package/eod/tail.config.js +7 -1
- package/gen_candidates.js +47 -42
- package/package.json +5 -5
package/eod/lib/http.js
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
const https = require('https');
|
|
24
24
|
|
|
25
|
+
// keep-alive 连接复用:省掉每次请求的 TCP/TLS 握手(全流程 ~500-700 次请求)。
|
|
26
|
+
// 经验证(Node 22):空闲套接字不阻塞进程退出,脚本可正常结束。
|
|
27
|
+
const AGENT = new https.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 8, keepAliveMsecs: 30000 });
|
|
28
|
+
|
|
25
29
|
// 用完整浏览器 UA:残缺 UA(如裸 "Mozilla/5.0")本身就是风控指纹特征。
|
|
26
30
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
27
31
|
|
|
@@ -60,68 +64,67 @@ function looksLikeWaf(txt) {
|
|
|
60
64
|
* - isWaf 自定义「这条响应算不算被拦截」判定(默认 looksLikeWaf)
|
|
61
65
|
* @returns {Promise<any>} 文本或已解析的 JSON
|
|
62
66
|
*/
|
|
63
|
-
function httpGet(url, opts = {}) {
|
|
67
|
+
async function httpGet(url, opts = {}) {
|
|
64
68
|
const {
|
|
65
69
|
enc = 'utf8', json = false, retries = 4, timeout = 15000,
|
|
66
70
|
referer = 'https://gu.qq.com/', isWaf = null,
|
|
67
71
|
} = opts;
|
|
68
72
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
96
|
-
req.on('error', rej);
|
|
97
|
-
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
73
|
+
let lastErr = null;
|
|
74
|
+
let wafHits = 0;
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < retries; i++) {
|
|
77
|
+
if (i > 0) {
|
|
78
|
+
// 被拦截 -> 长退避;普通网络抖动 -> 短退避
|
|
79
|
+
const wait = (lastErr && lastErr.waf)
|
|
80
|
+
? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
|
|
81
|
+
: 500 * i;
|
|
82
|
+
await sleep(wait);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const { status, txt } = await new Promise((res, rej) => {
|
|
86
|
+
const req = https.get(url, {
|
|
87
|
+
timeout,
|
|
88
|
+
agent: AGENT,
|
|
89
|
+
headers: {
|
|
90
|
+
'User-Agent': UA,
|
|
91
|
+
Referer: referer,
|
|
92
|
+
Accept: '*/*',
|
|
93
|
+
'Accept-Language': 'zh-CN,zh;q=0.9',
|
|
94
|
+
},
|
|
95
|
+
}, (resp) => {
|
|
96
|
+
const chunks = [];
|
|
97
|
+
resp.on('data', (c) => chunks.push(c));
|
|
98
|
+
resp.on('end', () => res({ status: resp.statusCode, txt: decode(Buffer.concat(chunks), enc) }));
|
|
98
99
|
});
|
|
100
|
+
req.on('error', rej);
|
|
101
|
+
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
102
|
+
});
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
} catch (e) {
|
|
120
|
-
lastErr = e;
|
|
104
|
+
// WAF 拦截:状态码判定 + 正文嗅探,两者都查
|
|
105
|
+
const waf = (isWaf ? isWaf(txt, status) : false) || status === 501 || looksLikeWaf(txt);
|
|
106
|
+
if (waf) {
|
|
107
|
+
wafHits++;
|
|
108
|
+
const e = new Error(`被接口反爬拦截(HTTP ${status})`);
|
|
109
|
+
e.waf = true;
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
if (status && status >= 400) throw new Error(`HTTP ${status}`);
|
|
113
|
+
|
|
114
|
+
if (!json) return txt.trim();
|
|
115
|
+
const body = txt.trim();
|
|
116
|
+
try { return JSON.parse(body); }
|
|
117
|
+
catch (e) {
|
|
118
|
+
// 兼容 JSONP 包裹:name({...});
|
|
119
|
+
const m = body.match(/^[a-zA-Z0-9_$.]+\(([\s\S]*)\);?$/);
|
|
120
|
+
if (m) { try { return JSON.parse(m[1]); } catch (e2) { /* 落到下面报错 */ } }
|
|
121
|
+
throw new Error('JSON 解析失败: ' + body.slice(0, 120));
|
|
121
122
|
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
lastErr = e;
|
|
122
125
|
}
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
}
|
|
127
|
+
throw lastErr || new Error('httpGet 失败');
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
/**
|
package/eod/lib/preopen_store.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* ---------------------------------------------------------------------------
|
|
5
5
|
* 一天一份文件:preopen/data/preopen-YYYYMMDD.json
|
|
6
6
|
* - records: code -> 候选明细(含 P1/P2/P3 分项、基线、标签、总分)
|
|
7
|
+
* - nearMiss: 贴线样本(总分落在 [minTotal-10, minTotal) 的 Top10,带完整分项)——
|
|
8
|
+
* 竞价特征无法事后从 K 线重建,被拒样本不留就无法回溯调「入选线」(2026-10-22 补)
|
|
9
|
+
* - meta.rejByReason: 各淘汰原因计数(漏斗/通过率分析)
|
|
7
10
|
* - runs[]: 当日多次运行留痕(幂等:同文件整文件覆盖,records 按 code 唯一)
|
|
8
11
|
* - close:收盘回填后追加当日收盘/涨跌幅/是否涨停/命中标记(preopen_backfill.js)
|
|
9
12
|
*
|
|
@@ -27,10 +30,13 @@ function loadRun(cfg, tradeDate) {
|
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
32
|
* 落库(幂等:同文件整文件覆盖,runs[] 追加本次运行)。
|
|
30
|
-
* @param {object} p { cfg, tradeDate, scope, candidates, meta, marketDate }
|
|
33
|
+
* @param {object} p { cfg, tradeDate, scope, candidates, nearMiss?, rejByReason?, meta, marketDate }
|
|
34
|
+
* - nearMiss:贴线样本(总分 [minTotal-10, minTotal) 的 Top10,带完整分项),
|
|
35
|
+
* 供日后调「入选线」用;竞价特征无法事后重建,必须运行时留。语义 = 最近一次运行覆盖。
|
|
36
|
+
* - rejByReason:各淘汰原因计数(漏斗分析用)。
|
|
31
37
|
* @returns {{file, recordCount, runCount}}
|
|
32
38
|
*/
|
|
33
|
-
function saveRun({ cfg, tradeDate, scope, candidates, meta, marketDate }) {
|
|
39
|
+
function saveRun({ cfg, tradeDate, scope, candidates, nearMiss, rejByReason, meta, marketDate }) {
|
|
34
40
|
const dir = path.join(paths.eodDir(), cfg.store.dataDir);
|
|
35
41
|
fs.mkdirSync(dir, { recursive: true });
|
|
36
42
|
const f = dataFile(cfg, tradeDate);
|
|
@@ -55,10 +61,13 @@ function saveRun({ cfg, tradeDate, scope, candidates, meta, marketDate }) {
|
|
|
55
61
|
marketDate,
|
|
56
62
|
meta: Object.assign({}, meta, { recordCount: Object.keys(records).length, runCount: runs.length }),
|
|
57
63
|
runs,
|
|
64
|
+
// 贴线样本(最近一次运行覆盖);rejByReason 挂在 meta 里
|
|
65
|
+
nearMiss: Array.isArray(nearMiss) ? nearMiss : (prev.nearMiss || []),
|
|
58
66
|
// 保留历史回填结果(若有)
|
|
59
67
|
close: prev.close || null,
|
|
60
68
|
records,
|
|
61
69
|
};
|
|
70
|
+
if (rejByReason && Object.keys(rejByReason).length) doc.meta.rejByReason = rejByReason;
|
|
62
71
|
fs.writeFileSync(f, JSON.stringify(doc, null, 2), 'utf8');
|
|
63
72
|
return { file: f, recordCount: doc.meta.recordCount, runCount: runs.length };
|
|
64
73
|
}
|
package/eod/preopen_screener.js
CHANGED
|
@@ -249,6 +249,7 @@ async function run() {
|
|
|
249
249
|
// 规则 + 打分
|
|
250
250
|
log('[3/4] 应用 P1/P2/P3 打分…');
|
|
251
251
|
const candidates = [];
|
|
252
|
+
const nearMissAll = [];
|
|
252
253
|
const gaps = [];
|
|
253
254
|
for (const q of quotes) {
|
|
254
255
|
const b = baseline.get(q.code);
|
|
@@ -261,10 +262,8 @@ async function run() {
|
|
|
261
262
|
const p2 = PR.calcP2(q, b, cfg);
|
|
262
263
|
const p3 = PR.calcP3(b, cfg);
|
|
263
264
|
const sc = PR.scorePreopen(p1, p2, p3, cfg);
|
|
264
|
-
if (sc.total < cfg.score.minTotal) continue;
|
|
265
|
-
|
|
266
265
|
const sector = sectorOf(map, q.code) || UNKNOWN;
|
|
267
|
-
|
|
266
|
+
const rec = {
|
|
268
267
|
code: q.code, name: q.name, sector,
|
|
269
268
|
board: /sh68[89]/.test(q.code) ? '科创板' : /^sz30/.test(q.code) ? '创业板' : /^bj/.test(q.code) ? '北交所' : '主板',
|
|
270
269
|
open: q.open, prevClose: q.prevClose, price: q.price,
|
|
@@ -277,9 +276,23 @@ async function run() {
|
|
|
277
276
|
total: +sc.total.toFixed(1),
|
|
278
277
|
scoreItems: { p1: +p1.score.toFixed(2), p2: +p2.score.toFixed(2), p3: +p3.score.toFixed(2) },
|
|
279
278
|
stamp: q.stamp,
|
|
280
|
-
}
|
|
279
|
+
};
|
|
280
|
+
if (sc.total < cfg.score.minTotal) {
|
|
281
|
+
// 贴线样本(2026-10-22 补):总分落在 [minTotal-10, minTotal) 的进归档 nearMiss 段。
|
|
282
|
+
// 竞价量比等特征无法事后从 K 线重建,被拒样本不留就无法回溯调「入选线」本身。
|
|
283
|
+
if (sc.total >= cfg.score.minTotal - 10) {
|
|
284
|
+
nearMissAll.push(Object.assign(rec, { belowMin: +(cfg.score.minTotal - sc.total).toFixed(1) }));
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
candidates.push(rec);
|
|
281
289
|
}
|
|
282
290
|
candidates.sort((a, b) => b.total - a.total);
|
|
291
|
+
nearMissAll.sort((a, b) => b.total - a.total);
|
|
292
|
+
const nearMiss = nearMissAll.slice(0, 10);
|
|
293
|
+
// 各淘汰原因计数(gaps 明细不落盘,只留统计——6000+ 只/天的明细太肥且没用)
|
|
294
|
+
const rejByReason = {};
|
|
295
|
+
for (const g of gaps) rejByReason[g.reason] = (rejByReason[g.reason] || 0) + 1;
|
|
283
296
|
|
|
284
297
|
// 分组
|
|
285
298
|
const bySector = new Map();
|
|
@@ -297,8 +310,9 @@ async function run() {
|
|
|
297
310
|
|
|
298
311
|
const result = {
|
|
299
312
|
candidates, groups, top: candidates.slice(0, cfg.group.topN),
|
|
300
|
-
stats: { snapshotCount: quotes.length, baselineCount: baseline.size, candidateCount: candidates.length, groupCount: groups.length, gapCount: gaps.length },
|
|
313
|
+
stats: { snapshotCount: quotes.length, baselineCount: baseline.size, candidateCount: candidates.length, groupCount: groups.length, gapCount: gaps.length, rejByReason },
|
|
301
314
|
gaps,
|
|
315
|
+
nearMiss,
|
|
302
316
|
};
|
|
303
317
|
|
|
304
318
|
log(`[4/4] 落库 + 报告 + 通知…`);
|
|
@@ -309,6 +323,7 @@ async function emitResult(p) {
|
|
|
309
323
|
const { tradeDate, scope, result, marketDate } = p;
|
|
310
324
|
const saved = store.saveRun({
|
|
311
325
|
cfg, tradeDate, scope, candidates: result.candidates,
|
|
326
|
+
nearMiss: result.nearMiss, rejByReason: result.stats.rejByReason,
|
|
312
327
|
meta: { snapshotCount: result.stats.snapshotCount, baselineCount: p.baselineCount, validQuotes: p.quotesCount, marketDate, elapsedMs: Date.now() - (p._t0 || Date.now()) },
|
|
313
328
|
marketDate,
|
|
314
329
|
});
|
package/eod/tail.config.js
CHANGED
|
@@ -30,7 +30,10 @@ const RANGES = [
|
|
|
30
30
|
];
|
|
31
31
|
|
|
32
32
|
module.exports = {
|
|
33
|
-
|
|
33
|
+
// 口径版本:写进每日归档 JSON 顶层与报告页脚,参数/打分口径变更时 +1,
|
|
34
|
+
// 回溯按版本切段(v1 = 2026-09-18~10-21 六项打分含 sector;v2 = 2026-10-22 起五项 35/24/18/12/11 + 新门槛)。
|
|
35
|
+
// ⚠️ 新旧版本的总分/分项不可直接横向比较,复盘对比必须先按 version 分组。
|
|
36
|
+
version: 2,
|
|
34
37
|
|
|
35
38
|
// =========================================================================
|
|
36
39
|
// 1. 交易时段(FR-01 / Q1:不依赖外部日历,靠行情时间戳 + 本地时钟判断)
|
|
@@ -212,6 +215,9 @@ module.exports = {
|
|
|
212
215
|
// 观察模式(14:30~14:50)另存 eod-<交易日>-obs.json,不污染正式口径;
|
|
213
216
|
// 一天一个文件,多次观察靠 runs[] 留痕(--stamp 才会在文件名里带时点)。
|
|
214
217
|
keepRunHistory: 20, // 每个文件最多保留多少条运行历史
|
|
218
|
+
// raw/(快照+分时原始存档)**永久保留**(2026-10-22 确认):
|
|
219
|
+
// 它是「改打分权重后离线重算历史」的唯一凭据(.tmp/smoke_newcfg.js 即靠它冒烟),
|
|
220
|
+
// 单份几 MB,磁盘成本可忽略;删除 = 该日历史永远只能按旧口径分析。
|
|
215
221
|
},
|
|
216
222
|
|
|
217
223
|
// =========================================================================
|
package/gen_candidates.js
CHANGED
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
const fs = require('fs');
|
|
43
43
|
const path = require('path');
|
|
44
44
|
const https = require('https');
|
|
45
|
+
// keep-alive 连接复用(与 eod/lib/http.js 同口径):观察池逐只抓取 ~360×3 类请求,
|
|
46
|
+
// 复用 TCP/TLS 省掉大半握手耗时。空闲套接字经验证不阻塞进程退出(Node 22)。
|
|
47
|
+
const AGENT = new https.Agent({ keepAlive: true, maxSockets: 8, maxFreeSockets: 8, keepAliveMsecs: 30000 });
|
|
45
48
|
const { now: nowStr, dayjs } = require('./time');
|
|
46
49
|
// 工作目录解析:产物/配置的落点走 paths,代码资产(本文件等)仍锚在包内。
|
|
47
50
|
// 默认 HOME = 包根(与历史 __dirname 行为一致);可用 CANDIDATE_POOL_HOME / --cwd 重定位。
|
|
@@ -50,7 +53,7 @@ const paths = require('./paths');
|
|
|
50
53
|
const scaffold = require('./scaffold');
|
|
51
54
|
// 规则引擎纯函数(R01/R07/R05/行业中位数/成交量单位/时区锚定)统一走 rules.js,
|
|
52
55
|
// 单测见 rules.test.js,避免与 gen_candidates 内联实现漂移导致双份真相。
|
|
53
|
-
const { lastClosedTradingDay, nextTradingDay, calcTurnover, computeSectors, calcR01, isLaggard, verifyR05 } = require('./rules');
|
|
56
|
+
const { lastClosedTradingDay, nextTradingDay, calcTurnover, computeSectors, calcR01, isLaggard, verifyR05, G } = require('./rules');
|
|
54
57
|
|
|
55
58
|
// ---------- 数据源:腾讯公开行情接口(无需任何第三方 CLI / 内置技能)----------
|
|
56
59
|
const QT_QUOTE = 'https://qt.gtimg.cn/q=';
|
|
@@ -121,44 +124,42 @@ function looksLikeWaf(txt) {
|
|
|
121
124
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
122
125
|
const WAF_BACKOFF = [2000, 5000, 12000, 25000]; // 命中拦截时的退避梯度(ms)
|
|
123
126
|
|
|
124
|
-
function httpGet(url, { json = true, retries = 4, timeout = 15000 } = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
: 500 * i);
|
|
134
|
-
}
|
|
135
|
-
try {
|
|
136
|
-
const { status, txt } = await new Promise((res, rej) => {
|
|
137
|
-
const req = https.get(url, { timeout, headers: { 'User-Agent': UA, 'Referer': 'https://gu.qq.com/', 'Accept': '*/*' } }, (resp) => {
|
|
138
|
-
let d = ''; resp.on('data', c => d += c); resp.on('end', () => res({ status: resp.statusCode, txt: d.trim() }));
|
|
139
|
-
});
|
|
140
|
-
req.on('error', rej);
|
|
141
|
-
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
142
|
-
});
|
|
143
|
-
// WAF 拦截返回 HTTP 501 + 跳转页;状态码判定比嗅探 body 更可靠,两者都查。
|
|
144
|
-
if (status === 501 || looksLikeWaf(txt)) {
|
|
145
|
-
wafHits++;
|
|
146
|
-
const e = new Error(`被接口反爬拦截(HTTP ${status})`);
|
|
147
|
-
e.waf = true;
|
|
148
|
-
throw e;
|
|
149
|
-
}
|
|
150
|
-
if (status && status >= 400) throw new Error(`HTTP ${status}`);
|
|
151
|
-
if (!json) return resolve(txt);
|
|
152
|
-
try { return resolve(JSON.parse(txt)); }
|
|
153
|
-
catch (e) {
|
|
154
|
-
const m = txt.match(/^[a-zA-Z0-9_]+\(([\s\S]*)\);?\s*$/);
|
|
155
|
-
if (m) { try { return resolve(JSON.parse(m[1])); } catch (e2) {} }
|
|
156
|
-
throw new Error('JSON 解析失败: ' + txt.slice(0, 120));
|
|
157
|
-
}
|
|
158
|
-
} catch (e) { lastErr = e; }
|
|
127
|
+
async function httpGet(url, { json = true, retries = 4, timeout = 15000 } = {}) {
|
|
128
|
+
let lastErr;
|
|
129
|
+
let wafHits = 0;
|
|
130
|
+
for (let i = 0; i < retries; i++) {
|
|
131
|
+
if (i > 0) {
|
|
132
|
+
// 拦截用长退避,普通网络错误用短退避
|
|
133
|
+
await sleep(lastErr && lastErr.waf
|
|
134
|
+
? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
|
|
135
|
+
: 500 * i);
|
|
159
136
|
}
|
|
160
|
-
|
|
161
|
-
|
|
137
|
+
try {
|
|
138
|
+
const { status, txt } = await new Promise((res, rej) => {
|
|
139
|
+
const req = https.get(url, { timeout, agent: AGENT, headers: { 'User-Agent': UA, 'Referer': 'https://gu.qq.com/', 'Accept': '*/*' } }, (resp) => {
|
|
140
|
+
let d = ''; resp.on('data', c => d += c); resp.on('end', () => res({ status: resp.statusCode, txt: d.trim() }));
|
|
141
|
+
});
|
|
142
|
+
req.on('error', rej);
|
|
143
|
+
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
144
|
+
});
|
|
145
|
+
// WAF 拦截返回 HTTP 501 + 跳转页;状态码判定比嗅探 body 更可靠,两者都查。
|
|
146
|
+
if (status === 501 || looksLikeWaf(txt)) {
|
|
147
|
+
wafHits++;
|
|
148
|
+
const e = new Error(`被接口反爬拦截(HTTP ${status})`);
|
|
149
|
+
e.waf = true;
|
|
150
|
+
throw e;
|
|
151
|
+
}
|
|
152
|
+
if (status && status >= 400) throw new Error(`HTTP ${status}`);
|
|
153
|
+
if (!json) return txt;
|
|
154
|
+
try { return JSON.parse(txt); }
|
|
155
|
+
catch (e) {
|
|
156
|
+
const m = txt.match(/^[a-zA-Z0-9_]+\(([\s\S]*)\);?\s*$/);
|
|
157
|
+
if (m) { try { return JSON.parse(m[1]); } catch (e2) {} }
|
|
158
|
+
throw new Error('JSON 解析失败: ' + txt.slice(0, 120));
|
|
159
|
+
}
|
|
160
|
+
} catch (e) { lastErr = e; }
|
|
161
|
+
}
|
|
162
|
+
throw lastErr || new Error('httpGet 失败');
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
// 候选源:HOME 下 candidates.json 配置的可编辑观察池(替代 westock ranking CompScore)
|
|
@@ -423,6 +424,10 @@ async function main() {
|
|
|
423
424
|
paths.ensureDir(path.dirname(snapshotPath)); // --dump 指向新目录时也要能写出去
|
|
424
425
|
const snap = {
|
|
425
426
|
anchor, target, limit, topN,
|
|
427
|
+
generatedAt: new Date().toISOString(),
|
|
428
|
+
// 快照当时的规则常量(rules.js G):日后调参(如 CHG_MIN 3→4)时,
|
|
429
|
+
// 历史门槛可按当时的常量重算,也可按新常量对 stocks[].chg 等原始值离线重切。
|
|
430
|
+
rulesG: { ...G },
|
|
426
431
|
breadth, breadthIsAnchor, overview,
|
|
427
432
|
stocks: stocks.map(s => { const c = Object.assign({}, s); delete c.kline; delete c.quote; delete c.minute; return c; }),
|
|
428
433
|
sectors: validSectors.map(s => { const c = Object.assign({}, s); delete c.members; return c; }),
|
|
@@ -483,7 +488,7 @@ function buildHTML(m) {
|
|
|
483
488
|
|
|
484
489
|
// 核心结论
|
|
485
490
|
const best = [...universe].sort((a, b) => (b.r01.core || 0) - (a.r01.core || 0))[0];
|
|
486
|
-
const bestStr = best && best.r01.core ? `${esc(best.name)}(${best.code.replace(/^
|
|
491
|
+
const bestStr = best && best.r01.core ? `${esc(best.name)}(${best.code.replace(/^(sh|sz|bj)/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
|
|
487
492
|
const conclusion = [
|
|
488
493
|
`<b>${b('R01 ' + (r01Triggers ? `硬触发 ${r01Triggers} 只` : '零硬触发'), 'R01 ' + (r01Triggers ? `${r01Triggers} hard trigger(s)` : 'zero hard triggers'))}。</b>` +
|
|
489
494
|
(r01Triggers ? '' : `${universe.length} 只标的无一满足全部门槛。最接近者为 ${bestStr}。`),
|
|
@@ -518,7 +523,7 @@ function buildHTML(m) {
|
|
|
518
523
|
const tier = tierOf.get(s.code) || 'excluded';
|
|
519
524
|
const cls = r.ok ? ' class="hit"' : '';
|
|
520
525
|
matrixRows += `<tr data-tier="${tier}"${cls}>` +
|
|
521
|
-
`<td>${s.code.replace(/^
|
|
526
|
+
`<td>${s.code.replace(/^(sh|sz|bj)/, '')}</td>` +
|
|
522
527
|
`<td>${b(s.name, s.name)}</td>` +
|
|
523
528
|
`<td><span class="tag tier-${tier}">${TIER_LABEL[tier]}</span></td>` +
|
|
524
529
|
`<td>${num(r.close)}</td>` +
|
|
@@ -541,7 +546,7 @@ function buildHTML(m) {
|
|
|
541
546
|
let exRows = '';
|
|
542
547
|
for (const s of excluded) {
|
|
543
548
|
exRows += `<tr>` +
|
|
544
|
-
`<td>${s.code.replace(/^
|
|
549
|
+
`<td>${s.code.replace(/^(sh|sz|bj)/, '')}</td>` +
|
|
545
550
|
`<td>${b(s.name, s.name)}</td>` +
|
|
546
551
|
`<td class="wrap">${(s.excludeReasons || ['—']).map(r => b(r, r)).join(';')}</td>` +
|
|
547
552
|
`</tr>`;
|
|
@@ -557,7 +562,7 @@ function buildHTML(m) {
|
|
|
557
562
|
const r07 = s.r07 || {};
|
|
558
563
|
return `<div class="card ${label === 'priority' ? 'priority' : ''}">` +
|
|
559
564
|
`<div class="grid-2">` +
|
|
560
|
-
`<div><div class="kv"><span class="label">${b('名称', 'Name')}:</span><span class="value">${b(s.name, s.name)}(${s.code.replace(/^
|
|
565
|
+
`<div><div class="kv"><span class="label">${b('名称', 'Name')}:</span><span class="value">${b(s.name, s.name)}(${s.code.replace(/^(sh|sz|bj)/, '')})</span></div>` +
|
|
561
566
|
`<div class="kv"><span class="label">${b('R01 达成', 'R01 score')}:</span><span class="value">${r.core}/4${r.capOk ? ',市值达标' : ',市值越线'}</span></div>` +
|
|
562
567
|
`<div class="kv"><span class="label">${b('行业', 'Sector')}:</span><span class="value">${r07.sectorName || '—'}(${pct(r07.sectorPct)},排名 ${r07.sectorRank || '—'}/${r07.total || '—'})</span></div></div>` +
|
|
563
568
|
`<div>${fund}` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candidate-pool",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
|
|
5
5
|
"bin": {
|
|
6
6
|
"candidate-pool": "bin/candidate-pool.js",
|
|
@@ -85,16 +85,16 @@
|
|
|
85
85
|
"prepare": "husky"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
|
-
"@commitlint/cli": "^21.2.
|
|
89
|
-
"@commitlint/config-conventional": "^21.2.
|
|
88
|
+
"@commitlint/cli": "^21.2.4",
|
|
89
|
+
"@commitlint/config-conventional": "^21.2.4",
|
|
90
90
|
"changelogen": "^0.6.2",
|
|
91
91
|
"husky": "^9.1.7"
|
|
92
92
|
},
|
|
93
93
|
"engines": {
|
|
94
|
-
"node": ">=
|
|
94
|
+
"node": ">=22.5"
|
|
95
95
|
},
|
|
96
96
|
"dependencies": {
|
|
97
97
|
"dayjs": "^1.11.23",
|
|
98
|
-
"dotenv": "^18.0.
|
|
98
|
+
"dotenv": "^18.0.8"
|
|
99
99
|
}
|
|
100
100
|
}
|