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/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
- return new Promise(async (resolve, reject) => {
70
- let lastErr = null;
71
- let wafHits = 0;
72
-
73
- for (let i = 0; i < retries; i++) {
74
- if (i > 0) {
75
- // 被拦截 -> 长退避;普通网络抖动 -> 短退避
76
- const wait = (lastErr && lastErr.waf)
77
- ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
78
- : 500 * i;
79
- await sleep(wait);
80
- }
81
- try {
82
- const { status, txt } = await new Promise((res, rej) => {
83
- const req = https.get(url, {
84
- timeout,
85
- headers: {
86
- 'User-Agent': UA,
87
- Referer: referer,
88
- Accept: '*/*',
89
- 'Accept-Language': 'zh-CN,zh;q=0.9',
90
- },
91
- }, (resp) => {
92
- const chunks = [];
93
- resp.on('data', (c) => chunks.push(c));
94
- resp.on('end', () => res({ status: resp.statusCode, txt: decode(Buffer.concat(chunks), enc) }));
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
- // WAF 拦截:状态码判定 + 正文嗅探,两者都查
101
- const waf = (isWaf ? isWaf(txt, status) : false) || status === 501 || looksLikeWaf(txt);
102
- if (waf) {
103
- wafHits++;
104
- const e = new Error(`被接口反爬拦截(HTTP ${status})`);
105
- e.waf = true;
106
- throw e;
107
- }
108
- if (status && status >= 400) throw new Error(`HTTP ${status}`);
109
-
110
- if (!json) return resolve(txt.trim());
111
- const body = txt.trim();
112
- try { return resolve(JSON.parse(body)); }
113
- catch (e) {
114
- // 兼容 JSONP 包裹:name({...});
115
- const m = body.match(/^[a-zA-Z0-9_$.]+\(([\s\S]*)\);?$/);
116
- if (m) { try { return resolve(JSON.parse(m[1])); } catch (e2) { /* 落到下面报错 */ } }
117
- throw new Error('JSON 解析失败: ' + body.slice(0, 120));
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
- reject(lastErr || new Error('httpGet 失败'));
124
- });
126
+ }
127
+ throw lastErr || new Error('httpGet 失败');
125
128
  }
126
129
 
127
130
  /**
@@ -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
  }
@@ -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
- candidates.push({
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
  });
@@ -30,7 +30,10 @@ const RANGES = [
30
30
  ];
31
31
 
32
32
  module.exports = {
33
- version: 1,
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
- return new Promise(async (resolve, reject) => {
126
- let lastErr;
127
- let wafHits = 0;
128
- for (let i = 0; i < retries; i++) {
129
- if (i > 0) {
130
- // 拦截用长退避,普通网络错误用短退避
131
- await sleep(lastErr && lastErr.waf
132
- ? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
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
- reject(lastErr || new Error('httpGet 失败'));
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(/^[sh|sz]/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
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(/^[sh|sz]/, '')}</td>` +
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(/^[sh|sz]/, '')}</td>` +
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(/^[sh|sz]/, '')})</span></div>` +
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.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.3",
89
- "@commitlint/config-conventional": "^21.2.3",
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": ">=18"
94
+ "node": ">=22.5"
95
95
  },
96
96
  "dependencies": {
97
97
  "dayjs": "^1.11.23",
98
- "dotenv": "^18.0.1"
98
+ "dotenv": "^18.0.8"
99
99
  }
100
100
  }