candidate-pool 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,94 @@
2
2
 
3
3
  所有变动按 **Conventional Commits** 约定分类,由 `changelogen` 自动生成。
4
4
 
5
+ ## v2.1.1
6
+
7
+ [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.1.0...v2.1.1)
8
+
9
+ ### 🐛 缺陷修复 (Bug Fixes)
10
+
11
+ - **eod:** 行业映射新增新浪兜底源与防降级闸,修复 runner 上东财被封月更失败 ([244e879](https://github.com/GuoSirius/candidate-pool/commit/244e879))
12
+
13
+ ### 📦 杂项维护 (Chores)
14
+
15
+ - 每日实时初筛 2026-09-22 ([7944ff2](https://github.com/GuoSirius/candidate-pool/commit/7944ff2))
16
+ - 尾盘观察 2026-09-23 ([901dafc](https://github.com/GuoSirius/candidate-pool/commit/901dafc))
17
+ - 尾盘选股 2026-09-23 ([ad60b3a](https://github.com/GuoSirius/candidate-pool/commit/ad60b3a))
18
+ - 每日实时初筛 2026-09-23 ([f48a456](https://github.com/GuoSirius/candidate-pool/commit/f48a456))
19
+ - 尾盘观察 2026-09-24 ([1f1d604](https://github.com/GuoSirius/candidate-pool/commit/1f1d604))
20
+ - 尾盘选股 2026-09-24 ([e1a4bee](https://github.com/GuoSirius/candidate-pool/commit/e1a4bee))
21
+ - 每日实时初筛 2026-09-24 ([3d01e09](https://github.com/GuoSirius/candidate-pool/commit/3d01e09))
22
+ - 尾盘观察 2026-09-28 ([bc2ad4e](https://github.com/GuoSirius/candidate-pool/commit/bc2ad4e))
23
+ - 尾盘选股 2026-09-28 ([c00a7a1](https://github.com/GuoSirius/candidate-pool/commit/c00a7a1))
24
+ - 每日实时初筛 2026-09-28 ([08a4291](https://github.com/GuoSirius/candidate-pool/commit/08a4291))
25
+ - 尾盘观察 2026-09-29 ([91a952e](https://github.com/GuoSirius/candidate-pool/commit/91a952e))
26
+ - 尾盘选股 2026-09-29 ([380e3da](https://github.com/GuoSirius/candidate-pool/commit/380e3da))
27
+ - 每日实时初筛 2026-09-29 ([edbfc55](https://github.com/GuoSirius/candidate-pool/commit/edbfc55))
28
+ - 尾盘观察 2026-09-30 ([60cd5a3](https://github.com/GuoSirius/candidate-pool/commit/60cd5a3))
29
+ - 尾盘选股 2026-09-30 ([02f5fc0](https://github.com/GuoSirius/candidate-pool/commit/02f5fc0))
30
+ - 每日实时初筛 2026-09-30 ([05f384f](https://github.com/GuoSirius/candidate-pool/commit/05f384f))
31
+ - 尾盘观察 2026-10-08 ([9a41a50](https://github.com/GuoSirius/candidate-pool/commit/9a41a50))
32
+ - 尾盘选股 2026-10-08 ([9618588](https://github.com/GuoSirius/candidate-pool/commit/9618588))
33
+ - 每日实时初筛 2026-10-08 ([72fdc6e](https://github.com/GuoSirius/candidate-pool/commit/72fdc6e))
34
+
35
+ ### ❤️ Contributors
36
+
37
+ - 郭之存 ([@siriusSupreme](https://github.com/siriusSupreme))
38
+
39
+ ## v2.1.0
40
+
41
+ [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.0.0...v2.1.0)
42
+
43
+ ### 🚀 新功能 (Features)
44
+
45
+ - 尾盘与候选池报告新增暗黑主题切换(默认暗色) ([a7fcfed](https://github.com/GuoSirius/candidate-pool/commit/a7fcfed))
46
+ - 尾盘报告改为单表双视图与行业筛选 ([327a87b](https://github.com/GuoSirius/candidate-pool/commit/327a87b))
47
+ - 主报告合并判定矩阵单表与档位筛选 ([9598f02](https://github.com/GuoSirius/candidate-pool/commit/9598f02))
48
+ - Tail 尾盘页双列表改为视图切换 ([5abf0dd](https://github.com/GuoSirius/candidate-pool/commit/5abf0dd))
49
+ - 新增盘后/盘中报告查看页(下拉选择 + iframe 预览) ([62d68a6](https://github.com/GuoSirius/candidate-pool/commit/62d68a6))
50
+ - 尾盘盘中版(intraday)口径入库并全链路可查 ([dab6bc1](https://github.com/GuoSirius/candidate-pool/commit/dab6bc1))
51
+ - Tail 行业筛选条两视图常驻并过滤候选表 ([0dce6e9](https://github.com/GuoSirius/candidate-pool/commit/0dce6e9))
52
+ - 尾盘报告加价格与总市值列 ([d5ca96f](https://github.com/GuoSirius/candidate-pool/commit/d5ca96f))
53
+
54
+ ### 🐛 缺陷修复 (Bug Fixes)
55
+
56
+ - Gen_candidates.js html 输出增加转义(安全红线) ([8a2d0e0](https://github.com/GuoSirius/candidate-pool/commit/8a2d0e0))
57
+ - Tail 分行业视图空态误显与行业多选筛选 ([c745a80](https://github.com/GuoSirius/candidate-pool/commit/c745a80))
58
+
59
+ ### ♻️ 代码重构 (Refactors)
60
+
61
+ - 抽出规则引擎纯函数 rules.js 并补 node:test 单测 ([bcda401](https://github.com/GuoSirius/candidate-pool/commit/bcda401))
62
+ - Gen_candidates 改用 rules.js 消除规则逻辑双写 ([d23b028](https://github.com/GuoSirius/candidate-pool/commit/d23b028))
63
+
64
+ ### 📚 文档 (Documentation)
65
+
66
+ - **web:** 补充导航高亮、序号列样式、分页汇总、尾盘阈值同步等硬约定 ([37ff9b0](https://github.com/GuoSirius/candidate-pool/commit/37ff9b0))
67
+ - 新增本地定时任务便携重建与对齐文档 ([715e31f](https://github.com/GuoSirius/candidate-pool/commit/715e31f))
68
+ - 新增代码审查标准流程与 pr 模板 ([f5c793c](https://github.com/GuoSirius/candidate-pool/commit/f5c793c))
69
+
70
+ ### 📦 杂项维护 (Chores)
71
+
72
+ - Update gitignore ([b183cc6](https://github.com/GuoSirius/candidate-pool/commit/b183cc6))
73
+ - 归档 0922 尾盘异动快照与报告 ([3102bed](https://github.com/GuoSirius/candidate-pool/commit/3102bed))
74
+ - 归档 0921 快照重取数据 ([5d63937](https://github.com/GuoSirius/candidate-pool/commit/5d63937))
75
+ - 历史报告批量重渲染为新版式 ([3a88595](https://github.com/GuoSirius/candidate-pool/commit/3a88595))
76
+ - 归档 0922 盘中运行数据 ([761cfdd](https://github.com/GuoSirius/candidate-pool/commit/761cfdd))
77
+ - 尾盘观察 2026-09-22 ([a0fd69f](https://github.com/GuoSirius/candidate-pool/commit/a0fd69f))
78
+ - 尾盘选股 2026-09-22 ([e15db04](https://github.com/GuoSirius/candidate-pool/commit/e15db04))
79
+
80
+ ### 🎨 代码格式 (Style)
81
+
82
+ - 尾盘报告容器加宽至 1280px 消除横向滚动 ([7c02776](https://github.com/GuoSirius/candidate-pool/commit/7c02776))
83
+
84
+ ### ⚙️ 持续集成 (CI)
85
+
86
+ - **workflow:** 本地准点 dispatch 触发 GitHub Actions,移除 schedule 延迟 ([0c05587](https://github.com/GuoSirius/candidate-pool/commit/0c05587))
87
+ - **workflow:** 恢复 GitHub 原生定时并配置 timezone/concurrency/timeout ([deca2f5](https://github.com/GuoSirius/candidate-pool/commit/deca2f5))
88
+
89
+ ### ❤️ Contributors
90
+
91
+ - 郭之存 ([@siriusSupreme](https://github.com/siriusSupreme))
92
+
5
93
  ## v2.0.0
6
94
 
7
95
  [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v1.1.0...v2.0.0)
package/db/schema.sql CHANGED
@@ -132,7 +132,7 @@ CREATE TABLE IF NOT EXISTS stock_note (
132
132
 
133
133
  -- ---------------------------------------------------------------------------
134
134
  -- 尾盘选股:运行批次(一个交易日 × 一种口径一行)。
135
- -- mode = 'formal'(14:50 固定口径)/ 'observe'(14:30~14:50 观察口径)。
135
+ -- mode = 'formal'(14:50 固定口径)/ 'observe'(14:30~14:50 观察口径)/ 'intraday'(盘中滚动窗口近似)。
136
136
  -- runs_json / stats_json 分别存当日多次运行历史与初筛漏斗统计(JSON)。
137
137
  -- ---------------------------------------------------------------------------
138
138
  CREATE TABLE IF NOT EXISTS tail_run (
@@ -158,7 +158,7 @@ CREATE TABLE IF NOT EXISTS tail_run (
158
158
  -- ---------------------------------------------------------------------------
159
159
  CREATE TABLE IF NOT EXISTS tail_pick (
160
160
  trade_date TEXT NOT NULL, -- 交易日(YYYY-MM-DD)
161
- mode TEXT NOT NULL, -- formal | observe
161
+ mode TEXT NOT NULL, -- formal | observe | intraday
162
162
  code TEXT NOT NULL, -- 股票代码(sh/sz/bj 前缀)
163
163
  name TEXT, -- 名称
164
164
  sector TEXT, -- 行业(东财/申万)
@@ -4,18 +4,31 @@
4
4
  * eod/build_industry_map.js —— 生成「代码 → 行业(+ 上市日期)」缓存
5
5
  * ---------------------------------------------------------------------------
6
6
  * 为什么单独做成低频脚本:
7
- * 行业归属几乎不变(一年也就调几次成分),但获取全市场映射必须走东财 clist
8
- * (一次 60 页、每页 100 只)。而**东财对突发高频请求会封 IP**(实测十余次请求后即
9
- * socket hang up),所以绝不能放进 14:50 的关键路径 —— 那里只读本脚本产出的缓存文件。
7
+ * 行业归属几乎不变(一年也就调几次成分),但获取全市场映射必须翻全市场列表,
8
+ * 而行情站对突发高频请求有风控(东财实测十余次请求即 socket hang up),所以
9
+ * 绝不能放进 14:50 的关键路径 —— 那里只读本脚本产出的缓存文件。
10
+ *
11
+ * 为什么需要双源(2026-10-01 月更事故):
12
+ * GitHub Actions runner 的机房段 IP 被**东财封禁**——月更运行第 1 页即在
13
+ * 3 个主机 × 3 次重试内全灭(第 1 页获取失败),0 只解析触发安全闸退出。
14
+ * 而腾讯行情(daily/tail 两个工作流的数据源)在 runner 上一直正常,说明
15
+ * 不是 runner 整体断网,而是东财单方面的机房 IP 风控。故增加一个在
16
+ * runner 上可达的兜底源。
10
17
  *
11
18
  * 源与降级:
12
19
  * 1. 东财 clist(主源,含 f100 行业 + f26 上市日期)
13
20
  * fs 覆盖 沪深A股 + 科创板 + 创业板 + 北交所;每页之间加节流,避免被封。
14
- * 2. 失败时**保留既有缓存文件**并以非零码退出,绝不用半截数据覆盖好数据。
21
+ * 2. 新浪行业分类(兜底源,runner 可达):newSinaHy.php 取行业节点列表,
22
+ * Market_Center.getHQNodeData 逐节点翻成分股(symbol 自带 sh/sz/bj 前缀)。
23
+ * 局限:不含上市日期(下游新股判定退回名称 N/C 前缀);北交所多数不在
24
+ * 新浪行业节点内,会落入统一的「未分类」桶。
25
+ * 任一源解析 < 3000 只即判定抓取不完整;两源都失败则**保留既有缓存**,
26
+ * 绝不用半截数据覆盖好数据。
15
27
  *
16
28
  * 用法:
17
- * node eod/build_industry_map.js # 刷新缓存
18
- * node eod/build_industry_map.js --dry # 只统计不写文件
29
+ * node eod/build_industry_map.js # 主源 -> 失败自动切兜底源
30
+ * node eod/build_industry_map.js --source=sina # 强制只走新浪源
31
+ * node eod/build_industry_map.js --dry # 只统计不写文件
19
32
  * ---------------------------------------------------------------------------
20
33
  */
21
34
 
@@ -27,6 +40,9 @@ const paths = require('../paths');
27
40
 
28
41
  // 输出位置跟随工作目录(见 paths.js):默认 <仓库根>/eod/data/industry-map.json
29
42
  const OUT = paths.industryMapFile();
43
+ const GATE = 3000; // 解析条数低于此值判定抓取不完整
44
+
45
+ // --------------------------- 源 1:东财 clist ------------------------------
30
46
 
31
47
  // 沪深A股 + 科创板 + 创业板 + 北交所。与全市场快照的代码覆盖面对齐。
32
48
  const FS_ALL = 'm:1+t:2,m:1+t:23,m:0+t:6,m:0+t:80,m:0+t:81+s:2048';
@@ -61,8 +77,8 @@ async function fetchPage(host, pn) {
61
77
  return httpGet(url, { json: true, retries: 3, timeout: 20000, referer: 'https://quote.eastmoney.com/' });
62
78
  }
63
79
 
64
- (async () => {
65
- const dry = process.argv.includes('--dry');
80
+ /** @returns {{stocks: Object, label: string} | null} 不完整时返回 null(不抛出) */
81
+ async function fetchFromEastmoney() {
66
82
  let host = null;
67
83
  const stocks = {};
68
84
  let page = 1;
@@ -111,14 +127,109 @@ async function fetchPage(host, pn) {
111
127
  process.stdout.write('\n');
112
128
 
113
129
  const n = Object.keys(stocks).length;
114
- console.log(`\n共解析 ${n} 只(${(n / (total || 1) * 100).toFixed(1)}% 覆盖)`);
130
+ console.log(`\n[东财源] 共解析 ${n} 只(${(n / (total || 1) * 100).toFixed(1)}% 覆盖)`);
131
+ if (n < GATE) return null;
132
+ return { stocks, label: `eastmoney clist (${host})` };
133
+ }
134
+
135
+ // --------------------------- 源 2:新浪行业分类 ----------------------------
136
+
137
+ const SINA_BASE = 'https://vip.stock.finance.sina.com.cn';
138
+ const SINA_REFERER = 'https://finance.sina.com.cn/';
139
+ const SINA_THROTTLE_MS = 150;
140
+ const SINA_NUM = 100; // getHQNodeData 单页上限
141
+
142
+ /** 行业节点列表:GBK 编码的 var 声明,值为 "key,行业名,数量,..." */
143
+ async function fetchSinaNodes() {
144
+ const txt = await httpGet(`${SINA_BASE}/q/view/newSinaHy.php`,
145
+ { enc: 'gbk', retries: 3, timeout: 20000, referer: SINA_REFERER });
146
+ const m = txt.match(/\{[\s\S]*\}/);
147
+ if (!m) throw new Error('新浪行业节点列表解析失败');
148
+ const obj = JSON.parse(m[0]);
149
+ const nodes = Object.entries(obj)
150
+ .map(([key, v]) => {
151
+ const p = String(v).split(',');
152
+ return { key, name: (p[1] || '').trim(), count: parseInt(p[2], 10) || 0 };
153
+ })
154
+ .filter((x) => x.name && x.count > 0);
155
+ if (!nodes.length) throw new Error('新浪行业节点为空');
156
+ return nodes;
157
+ }
158
+
159
+ /** @returns {{stocks: Object, label: string} | null} 单节点失败跳过,整体不完整时返回 null */
160
+ async function fetchFromSina() {
161
+ console.log('\n-- 切换兜底源:新浪行业分类 --');
162
+ let nodes;
163
+ try {
164
+ nodes = await fetchSinaNodes();
165
+ } catch (e) {
166
+ console.error(` 行业节点列表获取失败(${e.message})`);
167
+ return null;
168
+ }
169
+ console.log(` 行业节点 ${nodes.length} 个`);
170
+ const stocks = {};
171
+ for (const node of nodes) {
172
+ const pages = Math.ceil(node.count / SINA_NUM) || 1;
173
+ for (let p = 1; p <= pages; p++) {
174
+ const url = `${SINA_BASE}/quotes_service/api/json_v2.php/Market_Center.getHQNodeData`
175
+ + `?page=${p}&num=${SINA_NUM}&node=${node.key}&sort=symbol&asc=1`;
176
+ let rows;
177
+ try {
178
+ rows = await httpGet(url, { json: true, retries: 2, timeout: 20000, referer: SINA_REFERER });
179
+ } catch (e) {
180
+ console.error(`\n 节点「${node.name}」第 ${p} 页失败(${e.message}),跳过该节点`);
181
+ break;
182
+ }
183
+ if (!Array.isArray(rows) || !rows.length) break;
184
+ for (const r of rows) {
185
+ const sym = String(r.symbol || '');
186
+ if (!/^(sh|sz|bj)\d{6}$/.test(sym)) continue;
187
+ stocks[sym] = { name: String(r.name || '').trim(), sector: node.name, listed: null };
188
+ }
189
+ process.stdout.write(`\r ${node.name}(累计 ${Object.keys(stocks).length}) `);
190
+ if (rows.length < SINA_NUM) break;
191
+ await sleep(SINA_THROTTLE_MS);
192
+ }
193
+ await sleep(SINA_THROTTLE_MS);
194
+ }
195
+ process.stdout.write('\n');
196
+
197
+ const n = Object.keys(stocks).length;
198
+ const sectors = new Set(Object.values(stocks).map((v) => v.sector)).size;
199
+ // 闸门按「该源自洽完整」判定:新浪行业分类宇宙本身只有 ~3000 只(分类偏旧),
200
+ // 不能套东财的 3000 阈值;以节点列表声明的总数量为预期,抓到 >= 90% 即算完整。
201
+ const expected = nodes.reduce((s, x) => s + x.count, 0);
202
+ const gate = Math.max(1000, Math.floor(expected * 0.9));
203
+ console.log(`\n[新浪源] 共解析 ${n} 只,${sectors} 个行业(预期 ${expected},闸门 ${gate})`);
204
+ if (n < gate) return null;
205
+ return { stocks, label: 'sina industry nodes (vip.stock.finance.sina.com.cn)' };
206
+ }
207
+
208
+ // ---------------------------------- 主流程 ---------------------------------
209
+
210
+ (async () => {
211
+ const dry = process.argv.includes('--dry');
212
+ const srcArg = process.argv.find((a) => a.startsWith('--source='));
213
+ const forced = srcArg ? srcArg.split('=')[1] : null; // 'eastmoney' | 'sina'
115
214
 
116
- if (n < 3000) {
117
- console.error(`\n✗ 解析条数过少(${n}),判定抓取不完整,**不覆盖既有缓存**。`);
118
- console.error(' 若东财封禁本机 IP,可稍后重试;期间站点会沿用旧缓存(不影响运行)。');
215
+ let result = null;
216
+ if (forced !== 'sina') {
217
+ result = await fetchFromEastmoney();
218
+ if (!result) console.error(' 东财源抓取不完整,尝试新浪兜底源…');
219
+ }
220
+ if (!result && forced !== 'eastmoney') {
221
+ result = await fetchFromSina();
222
+ }
223
+
224
+ if (!result) {
225
+ console.error(`\n✗ 两个源均解析不足 ${GATE} 只,判定抓取不完整,**不覆盖既有缓存**。`);
226
+ console.error(' 期间站点沿用旧缓存(不影响运行);可稍后重试或在本机手动执行。');
119
227
  process.exit(1);
120
228
  }
121
229
 
230
+ const { stocks, label } = result;
231
+ const n = Object.keys(stocks).length;
232
+
122
233
  // 行业分布概览
123
234
  const bySector = {};
124
235
  for (const v of Object.values(stocks)) bySector[v.sector] = (bySector[v.sector] || 0) + 1;
@@ -130,9 +241,18 @@ async function fetchPage(host, pn) {
130
241
 
131
242
  if (dry) { console.log('\n--dry:未写入文件'); return; }
132
243
 
244
+ // 防降级闸:部分抓取(如东财 74% 覆盖也 >= 3000 过闸)或兜底源(新浪仅 ~3000)
245
+ // 都不能覆盖既有的大映射 —— 缓存条数明显缩水视为降级,保留旧数据。
246
+ let prevCount = 0;
247
+ try { prevCount = JSON.parse(fs.readFileSync(OUT, 'utf8')).count || 0; } catch (e) { /* 首次运行无缓存 */ }
248
+ if (prevCount > n * 1.2) {
249
+ console.log(`\n既有缓存 ${prevCount} 条明显大于本次 ${n} 条,判定为覆盖面降级,**保留旧缓存不覆盖**。`);
250
+ return; // 缓存保留即视为成功,不触发工作流失败/提交
251
+ }
252
+
133
253
  const payload = {
134
254
  generatedAt: now(),
135
- source: `eastmoney clist (${host})`,
255
+ source: label,
136
256
  count: n,
137
257
  stocks,
138
258
  };
package/eod/lib/report.js CHANGED
@@ -82,7 +82,7 @@ function rowHtml(c, i, cfg, withBreakdown) {
82
82
  const cells = withBreakdown
83
83
  ? SCORE_COLS.map(([k], idx) => `<td class="${SCORE_CELL_CLS[idx]}">${num(c.score ? c.score[k] : null, 1)}</td>`).join('')
84
84
  : '';
85
- return `<tr>
85
+ return `<tr data-sector="${esc(c.sector)}">
86
86
  <td>${i + 1}</td>
87
87
  <td><b>${esc(c.name)}</b><span class="src">${c.code.replace(/^[a-z]+/, '')}</span></td>
88
88
  <td>${b(esc(c.sector), esc(c.sector))}</td>
@@ -90,27 +90,58 @@ function rowHtml(c, i, cfg, withBreakdown) {
90
90
  <td>${num(tail.segPct, 2)}%</td>
91
91
  <td>${num(c.volRatio)}</td>
92
92
  <td>${num(c.turnover, 1)}%</td>
93
+ <td>${num(c.price, 2)}</td>
93
94
  <td>${yi(c.floatCapYi)}</td>
95
+ <td>${yi(c.totalCapYi)}</td>
94
96
  ${cells}
95
97
  <td><span class="score">${num(c.total, 1)}</span></td>
96
98
  <td class="wrap">${tagBits.join('') || '—'}</td>
97
99
  </tr>`;
98
100
  }
99
101
 
100
- const CSS = ` :root {
102
+ const CSS = ` :root, :root[data-theme="light"] {
101
103
  --bg: #fffbeb; --card: #fff; --text: #1c1917; --muted: #92400e;
102
104
  --border: #fde68a; --accent: #b45309; --red: #dc2626; --green: #16a34a;
103
105
  --blue: #2563eb; --amber: #d97706; --gold: #ca8a04; --slate: #64748b;
106
+ --btn-bg: #fff; --btn-fg: #b45309;
107
+ --callout-bg: #fff7ed;
108
+ --th-bg: #fefce8;
109
+ --grp-self-bg: #fed7aa; --grp-self-fg: #7c2d12;
110
+ --grp-ctx-bg: #e2e8f0; --grp-ctx-fg: #475569;
111
+ --grp-sub-self-bg: #fff7ed; --grp-sub-ctx-bg: #f1f5f9;
112
+ --tag-bg: #eff6ff; --tag-fail-bg: #f1f5f9; --tag-pass-bg: #fef2f2; --tag-gap-bg: #fff7ed;
113
+ --score-bg: #fffbeb;
114
+ --sc-self-bg: rgba(217, 119, 6, 0.06); --sc-ctx-bg: rgba(100, 116, 139, 0.05);
115
+ --note-fg: #57534e;
116
+ --disc-bg: #fef2f2; --disc-border: #fecaca; --disc-fg: #991b1b;
117
+ --card-empty-bg: #fafaf9; --hit-bg: #fffdf5; --note-bg: #f8fafc;
118
+ }
119
+ :root[data-theme="dark"] {
120
+ --bg: #0f1115; --card: #181b21; --text: #e7e9ec; --muted: #c9a684;
121
+ --border: #343a44; --accent: #f59e0b; --red: #f87171; --green: #4ade80;
122
+ --blue: #60a5fa; --amber: #fbbf24; --gold: #fbbf24; --slate: #94a3b8;
123
+ --btn-bg: #1f2329; --btn-fg: #f59e0b;
124
+ --callout-bg: #1f1c17;
125
+ --th-bg: #1d2128;
126
+ --grp-self-bg: #3a2a1a; --grp-self-fg: #fcd34d;
127
+ --grp-ctx-bg: #232831; --grp-ctx-fg: #94a3b8;
128
+ --grp-sub-self-bg: #1f1c17; --grp-sub-ctx-bg: #1d222b;
129
+ --tag-bg: #1b2533; --tag-fail-bg: #232831; --tag-pass-bg: #2a1e1e; --tag-gap-bg: #1f1c17;
130
+ --score-bg: #211d14;
131
+ --sc-self-bg: rgba(245, 158, 11, 0.10); --sc-ctx-bg: rgba(148, 163, 184, 0.08);
132
+ --note-fg: #b9b3a8;
133
+ --disc-bg: #2a1e1e; --disc-border: #5b2a2a; --disc-fg: #fca5a5;
134
+ --card-empty-bg: #14171c; --hit-bg: #20242b; --note-bg: #1b1f26;
104
135
  }
105
136
  * { box-sizing: border-box; margin: 0; padding: 0; }
106
137
  body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 24px 16px; }
107
- .container { max-width: 1040px; margin: 0 auto; }
138
+ .container { max-width: 1280px; margin: 0 auto; }
108
139
  .topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
109
140
  h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; }
110
141
  h2 { font-size: 1.15rem; font-weight: 600; margin: 30px 0 12px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
111
142
  .sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 14px; }
112
- #langBtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: #fff; color: var(--accent); font-weight: 600; font-size: 0.85rem; padding: 7px 16px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
113
- #langBtn:hover { background: var(--accent); color: #fff; }
143
+ .topbtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: var(--btn-bg); color: var(--btn-fg); font-weight: 600; font-size: 0.85rem; padding: 7px 16px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
144
+ .topbtn:hover { background: var(--accent); color: var(--btn-bg); }
114
145
  .meta-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 0.88rem; margin-bottom: 18px; }
115
146
  .meta-bar .label { color: var(--muted); }
116
147
  .meta-bar .value { font-weight: 600; }
@@ -153,7 +184,37 @@ const CSS = ` :root {
153
184
  summary { cursor: pointer; color: var(--accent); font-size: 0.88rem; font-weight: 600; }
154
185
  .src { font-size: 0.78rem; color: var(--slate); margin-top: 8px; }
155
186
  .disclaimer { margin-top: 26px; padding: 14px 18px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; font-size: 0.85rem; color: #991b1b; }
156
- .footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }`;
187
+ .footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
188
+ /* 暗黑主题:覆盖硬编码组件色(默认 data-theme="dark",见 <html>) */
189
+ :root[data-theme="dark"] .callout { background: var(--callout-bg); }
190
+ :root[data-theme="dark"] .score { background: var(--score-bg); color: var(--gold); }
191
+ :root[data-theme="dark"] .tag { background: var(--tag-bg); }
192
+ :root[data-theme="dark"] .tag.fail { background: var(--tag-fail-bg); }
193
+ :root[data-theme="dark"] .tag.pass { background: var(--tag-pass-bg); }
194
+ :root[data-theme="dark"] .tag.gap { background: var(--tag-gap-bg); }
195
+ :root[data-theme="dark"] th { background: var(--th-bg); }
196
+ :root[data-theme="dark"] th.grp-self { background: var(--grp-self-bg); color: var(--grp-self-fg); }
197
+ :root[data-theme="dark"] th.grp-ctx { background: var(--grp-ctx-bg); color: var(--grp-ctx-fg); }
198
+ :root[data-theme="dark"] th.grp-sub.grp-self { background: var(--grp-sub-self-bg); color: var(--accent); }
199
+ :root[data-theme="dark"] th.grp-sub.grp-ctx { background: var(--grp-sub-ctx-bg); color: var(--slate); }
200
+ :root[data-theme="dark"] td.sc-self { background: var(--sc-self-bg); }
201
+ :root[data-theme="dark"] td.sc-ctx { background: var(--sc-ctx-bg); }
202
+ :root[data-theme="dark"] .score-note { background: var(--callout-bg); color: var(--note-fg); }
203
+ :root[data-theme="dark"] .disclaimer { background: var(--disc-bg); border-color: var(--disc-border); color: var(--disc-fg); }
204
+ /* 候选明细工具条:视图切换 / 行业筛选 / 分项开关(单表双视图,替代原 TOP 表 + 分行业卡片双列表) */
205
+ .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 0 0 12px; }
206
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; flex: none; }
207
+ .segbtn { border: 0; background: var(--btn-bg); color: var(--btn-fg); font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 16px; cursor: pointer; white-space: nowrap; }
208
+ .segbtn + .segbtn { border-left: 1px solid var(--border); }
209
+ .segbtn.active { background: var(--accent); color: #fff; }
210
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
211
+ .chip { border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); font: inherit; font-size: 0.8rem; padding: 3px 11px; border-radius: 999px; cursor: pointer; }
212
+ .chip i { font-style: normal; color: var(--muted); margin-left: 3px; }
213
+ .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
214
+ .chip.active i { color: inherit; opacity: 0.85; }
215
+ .bd-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted); cursor: pointer; white-space: nowrap; flex: none; }
216
+ tr.sector-row td { background: var(--th-bg); color: var(--accent); font-weight: 700; font-size: 0.88rem; }
217
+ table.hide-bd th.grp, table.hide-bd th.grp-sub, table.hide-bd td.sc-self, table.hide-bd td.sc-ctx { display: none; }`;
157
218
 
158
219
  /**
159
220
  * @param {object} m { cfg, tradeDate, mode, cutHHMM, segFrom, result, meta, runner }
@@ -186,40 +247,49 @@ function buildHTML(m) {
186
247
  `Funnel: snapshot ${stats.snapshotCount} → pre-filter pass ${stats.prePassCount} → after tail checks ${stats.candidateCount}.`),
187
248
  ];
188
249
 
189
- // ---- TOP N 表 ----
190
- const topRows = top.map((c, i) => rowHtml(c, i, cfg, true)).join('');
191
250
  // 两级表头:第一行把六个子分切成「个股自身 65 / 环境与质量 35」两块,
192
251
  // 第一行列宽靠 colspan 自动对齐到下面的子分列,不需要额外配 colgroup。
193
252
  const topHead = `<tr><th rowspan="2">#</th><th rowspan="2">${b('标的', 'Name')}</th><th rowspan="2">${b('行业', 'Industry')}</th>
194
253
  <th rowspan="2">${b('涨幅', 'Chg')}</th><th rowspan="2">${b('尾盘段', 'Tail seg')}</th><th rowspan="2">${b('量比', 'Vol ratio')}</th>
195
- <th rowspan="2">${b('换手', 'Turnover')}</th><th rowspan="2">${b('流通值', 'Float cap')}</th>
254
+ <th rowspan="2">${b('换手', 'Turnover')}</th><th rowspan="2">${b('价格', 'Price')}</th>
255
+ <th rowspan="2">${b('流通值', 'Float cap')}</th><th rowspan="2">${b('总市值', 'Total cap')}</th>
196
256
  ${scoreHeadTop()}
197
257
  <th rowspan="2">${b('总分', 'Total')}</th><th rowspan="2">${b('标记', 'Flags')}</th></tr>
198
258
  <tr>${scoreHeadSub()}</tr>`;
199
259
 
200
260
  // 读分说明:不解释这一步,用户只会看到一堆等宽数字,不知道哪几个该当真。
201
- const scoreNote = `<p class="score-note">${b(
261
+ const scoreNote = `<p class="score-note" id="scoreNote">${b(
202
262
  `六维分项按「这分是谁给的」分成两组:<b class='sn-self'>个股自身 65 分</b>(尾盘动能 30 + 量能 20 + 位置 15)—— 这只票自己在走出来的证据,<b>重点看这一组</b>;<b class='sn-ctx'>环境与质量 35 分</b>(板块 15 + 均价线 10 + 换手 10)—— 板块与流动性给的背景分,靠它撑起来的高分不可信。`,
203
263
  `<b class='sn-self'>The stock itself — 65 pts</b> (tail momentum 30 + volume 20 + position 15): evidence this name moved on its own — <b>this is the group to read first</b>. <b class='sn-ctx'>Context &amp; quality — 35 pts</b> (sector 15 + VWAP 10 + turnover 10): background handed over by the sector and by liquidity; a high total carried by this group alone is not trustworthy.`)}</p>`;
204
264
 
205
- // ---- 分组卡片 ----
206
- const groupCards = groups.map((g) => {
207
- const rows = g.shown.map((c, i) => rowHtml(c, i, cfg, false)).join('');
208
- return `<div class="card">
209
- <div class="group-head">
210
- <h3>${esc(g.sector)}</h3>
211
- <span class="g-meta">${b(`组内 ${g.size} 只(展示 ${g.shown.length})`, `${g.size} picked, showing ${g.shown.length}`)}
212
- · ${b('行业中位涨幅', 'industry median')} ${pct(g.best.sectorMedianChg)}</span>
213
- </div>
214
- <div class="table-wrap"><table>
215
- <thead><tr><th>#</th><th>${b('标的', 'Name')}</th><th>${b('行业', 'Industry')}</th>
216
- <th>${b('涨幅', 'Chg')}</th><th>${b('尾盘段', 'Tail seg')}</th><th>${b('量比', 'Vol ratio')}</th>
217
- <th>${b('换手', 'Turnover')}</th><th>${b('流通值', 'Float cap')}</th>
218
- <th>${b('总分', 'Total')}</th><th>${b('标记', 'Flags')}</th></tr></thead>
219
- <tbody>${rows}</tbody>
220
- </table></div>
221
- </div>`;
222
- }).join('');
265
+ // ---- 候选明细(单表双视图):TOP 表与分行业卡片本是同一批候选的两种组织方式,
266
+ // 双列表翻页滚动成本高,故合并为一张表:默认按总分排序,「按行业」视图由前端
267
+ // 把行重排进分组分隔行之下;行业 chips 做筛选;分项 6 列可用开关收起。
268
+ // 展示上限沿用 report.maxRows(总量控制),列表为 top 之外的全量候选。----
269
+ const listed = candidates.slice(0, cfg.report.maxRows);
270
+ const truncated = candidates.length - listed.length;
271
+ const chipCounts = {};
272
+ for (const c of listed) { const k = c.sector || '未分类'; chipCounts[k] = (chipCounts[k] || 0) + 1; }
273
+ const chipOrder = groups.map((g) => g.sector).filter((s) => chipCounts[s]);
274
+ for (const k of Object.keys(chipCounts)) if (!chipOrder.includes(k)) chipOrder.push(k);
275
+ const chipsHtml = [`<button type="button" class="chip active" data-filter="">${b(`全部 ${listed.length}`, `All ${listed.length}`)}</button>`]
276
+ .concat(chipOrder.map((s) =>
277
+ `<button type="button" class="chip" data-filter="${esc(s)}">${b(esc(s), esc(s))}<i>${chipCounts[s]}</i></button>`))
278
+ .join('');
279
+ const toolbar = `<div class="toolbar">
280
+ <div class="seg" role="tablist">
281
+ <button type="button" id="viewScore" class="segbtn active">${b('按总分', 'By score')}</button>
282
+ <button type="button" id="viewSector" class="segbtn">${b('按行业', 'By industry')}</button>
283
+ </div>
284
+ <div class="chips" id="chips">${chipsHtml}</div>
285
+ <label class="bd-toggle"><input type="checkbox" id="bdToggle"${cfg.report.showScoreBreakdown ? ' checked' : ''}> ${b('分项得分', 'Breakdown')}</label>
286
+ </div>`;
287
+ // 分组分隔行:默认隐藏,「按行业」视图由前端插入到各组行首(含行业中位涨幅与组内数量)
288
+ // colspan = 基础 10 列(# 标的 行业 涨幅 尾盘段 量比 换手 价格 流通值 总市值)+ 分项 6 列 + 总分/标记 2 列
289
+ const sepRows = groups.map((g) =>
290
+ `<tr class="sector-row" data-sep="${esc(g.sector)}" hidden><td colspan="${10 + SCORE_COLS.length + 2}">${
291
+ b(esc(g.sector), esc(g.sector))} · ${b('中位涨幅', 'median')} ${pct(g.best.sectorMedianChg)} · ${b(`组内 ${g.size} 只`, `${g.size} picked`)}</td></tr>`).join('');
292
+ const candRows = listed.map((c, i) => rowHtml(c, i, cfg, true)).join('');
223
293
 
224
294
  // ---- 淘汰漏斗 ----
225
295
  const funnelRows = Object.entries(stats.preByReason)
@@ -241,7 +311,7 @@ function buildHTML(m) {
241
311
  const titleEn = `${cfg.report.titleEn} — ${tradeDate} ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)}`;
242
312
 
243
313
  return `<!DOCTYPE html>
244
- <html lang="zh-CN">
314
+ <html lang="zh-CN" data-theme="dark">
245
315
  <head>
246
316
  <meta charset="UTF-8">
247
317
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -256,7 +326,8 @@ function buildHTML(m) {
256
326
  <div class="sub">${b(`尾盘段 ${segFrom.slice(0, 2)}:${segFrom.slice(2)} → ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)} · 满分 100 · ${modeLabel}`,
257
327
  `Tail segment ${segFrom.slice(0, 2)}:${segFrom.slice(2)} → ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)} · max 100 · ${modeLabel}`)}</div>
258
328
  </div>
259
- <button id="langBtn" type="button">EN</button>
329
+ <button id="themeBtn" type="button" class="topbtn" title="切换深浅色">🌙</button>
330
+ <button id="langBtn" type="button" class="topbtn">EN</button>
260
331
  </div>
261
332
 
262
333
  <div class="meta-bar">
@@ -273,14 +344,13 @@ function buildHTML(m) {
273
344
  <ul>${conclusion.map((c) => `<li>${c}</li>`).join('')}</ul>
274
345
  </div>
275
346
 
276
- <h2>${b(`TOP ${top.length} 候选(分项得分:个股自身 65 + 环境质量 35)`,
277
- `Top ${top.length} Candidates (breakdown: stock 65 + context 35)`)}</h2>
278
- ${top.length ? `${scoreNote}<div class="table-wrap"><table><thead>${topHead}</thead><tbody>${topRows}</tbody></table></div>`
347
+ <h2>${b(`候选明细(${listed.length} 只)`, `Candidates (${listed.length})`)}</h2>
348
+ ${listed.length ? `${scoreNote}${toolbar}
349
+ <div class="table-wrap"><table id="candTable"><thead>${topHead}</thead>
350
+ <tbody id="candBody">${candRows}${sepRows}</tbody></table></div>
351
+ ${truncated > 0 ? `<p class="src">${b(`另有 ${truncated} 只超出展示上限(report.maxRows)未列出。`, `${truncated} more candidates not listed (report.maxRows cap).`)}</p>` : ''}`
279
352
  : `<div class="card">${b('本时段无满足全部门槛的候选。', 'No candidate passed all gates in this window.')}</div>`}
280
353
 
281
- <h2>${b('分行业明细', 'By Industry')}</h2>
282
- ${groupCards || `<div class="card">${b('无分组数据。', 'No group data.')}</div>`}
283
-
284
354
  <h2>${b('淘汰漏斗', 'Rejection Funnel')}</h2>
285
355
  <div class="card">
286
356
  <h3>${b('初筛阶段(快照字段即可判定)', 'Pre-filter (snapshot fields only)')}</h3>
@@ -303,6 +373,23 @@ function buildHTML(m) {
303
373
  </div>
304
374
  <script>
305
375
  (function () {
376
+ // 主题切换:默认暗色(<html data-theme="dark">),选择持久化到 localStorage
377
+ var themeBtn = document.getElementById('themeBtn');
378
+ var root = document.documentElement;
379
+ var savedTheme = null;
380
+ try { savedTheme = localStorage.getItem('report-theme'); } catch (e) {}
381
+ if (savedTheme === 'light' || savedTheme === 'dark') root.setAttribute('data-theme', savedTheme);
382
+ function syncThemeLabel() {
383
+ var t = root.getAttribute('data-theme');
384
+ themeBtn.textContent = (t === 'dark') ? '☀' : '🌙';
385
+ }
386
+ syncThemeLabel();
387
+ themeBtn.addEventListener('click', function () {
388
+ var t = (root.getAttribute('data-theme') === 'dark') ? 'light' : 'dark';
389
+ root.setAttribute('data-theme', t);
390
+ try { localStorage.setItem('report-theme', t); } catch (e) {}
391
+ syncThemeLabel();
392
+ });
306
393
  var lang = 'zh';
307
394
  var btn = document.getElementById('langBtn');
308
395
  var nodes = document.querySelectorAll('[data-zh]');
@@ -320,6 +407,64 @@ function buildHTML(m) {
320
407
  }
321
408
  btn.addEventListener('click', function () { lang = (lang === 'zh') ? 'en' : 'zh'; apply(lang); });
322
409
  apply(lang);
410
+
411
+ // 候选明细:视图切换(总分/行业)+ 行业 chips 筛选 + 分项列开关。
412
+ // 行都在服务端渲染好,「按行业」只是把行重排进各分组分隔行之下,不重新请求/重建 DOM。
413
+ var tbody = document.getElementById('candBody');
414
+ if (tbody) {
415
+ var candRows = Array.prototype.slice.call(tbody.querySelectorAll('tr[data-sector]'));
416
+ var sepOrder = [];
417
+ Array.prototype.forEach.call(tbody.querySelectorAll('tr[data-sep]'), function (tr) { sepOrder.push(tr); });
418
+ var candView = 'score', candFilter = '';
419
+ var viewScoreBtn = document.getElementById('viewScore');
420
+ var viewSectorBtn = document.getElementById('viewSector');
421
+ function renderCand() {
422
+ var frag = document.createDocumentFragment();
423
+ if (candView === 'score') {
424
+ candRows.forEach(function (tr) {
425
+ if (!candFilter || tr.getAttribute('data-sector') === candFilter) frag.appendChild(tr);
426
+ });
427
+ } else {
428
+ sepOrder.forEach(function (sep) {
429
+ var sec = sep.getAttribute('data-sep');
430
+ if (candFilter && sec !== candFilter) return;
431
+ sep.hidden = false; frag.appendChild(sep);
432
+ candRows.forEach(function (tr) {
433
+ if (tr.getAttribute('data-sector') === sec) frag.appendChild(tr);
434
+ });
435
+ });
436
+ }
437
+ while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
438
+ tbody.appendChild(frag);
439
+ }
440
+ function setView(v) {
441
+ candView = v;
442
+ viewScoreBtn.classList.toggle('active', v === 'score');
443
+ viewSectorBtn.classList.toggle('active', v === 'sector');
444
+ renderCand();
445
+ }
446
+ viewScoreBtn.addEventListener('click', function () { setView('score'); });
447
+ viewSectorBtn.addEventListener('click', function () { setView('sector'); });
448
+ document.getElementById('chips').addEventListener('click', function (e) {
449
+ var chip = e.target.closest('.chip');
450
+ if (!chip) return;
451
+ candFilter = chip.getAttribute('data-filter') || '';
452
+ Array.prototype.forEach.call(document.querySelectorAll('#chips .chip'), function (c) {
453
+ c.classList.toggle('active', (c.getAttribute('data-filter') || '') === candFilter);
454
+ });
455
+ renderCand();
456
+ });
457
+ var bdToggle = document.getElementById('bdToggle');
458
+ var scoreNote = document.getElementById('scoreNote');
459
+ function applyBd() {
460
+ var on = bdToggle.checked;
461
+ document.getElementById('candTable').classList.toggle('hide-bd', !on);
462
+ if (scoreNote) scoreNote.style.display = on ? '' : 'none';
463
+ }
464
+ bdToggle.addEventListener('change', applyBd);
465
+ applyBd();
466
+ renderCand();
467
+ }
323
468
  })();
324
469
  </script>
325
470
  </body>
package/eod/lib/store.js CHANGED
@@ -37,6 +37,7 @@ function toRecord(c) {
37
37
  volRatio: c.volRatio,
38
38
  volRatioEst: c.volRatioEst ?? null,
39
39
  floatCapYi: c.floatCapYi,
40
+ totalCapYi: c.totalCapYi,
40
41
  avgPrice: c.avgPrice,
41
42
  groupRank: c.groupRank,
42
43
  bestInGroup: c.bestInGroup,
@@ -16,7 +16,7 @@
16
16
  * - **口径必须翻译**:本地脚本内部把「14:50 固定口径」叫 `cut`(eod/lib/trading.js 的
17
17
  * `mode`,也是文件名分桶依据 —— daySuffix('cut') === ''),而 D1 / 网页 API 只有
18
18
  * formal / observe(db/schema.sql、worker/src/types.ts)。落库前统一走 d1Mode(),
19
- * 否则网页拿到 `cut` 会 400「mode 只能为 formal / observe」,记录页也会把它兜底显示成「观察」。
19
+ * 否则网页拿到 `cut` 会 400「mode 只能为 formal / observe / intraday」,记录页也会把它兜底显示成「观察」。
20
20
  * (2026-09-21 事故:当天 14:50 的正式运行就是这样写进去的。)
21
21
  */
22
22
  const path = require('path');
@@ -26,12 +26,13 @@ const clients = require('../../db/clients');
26
26
 
27
27
  /**
28
28
  * 本地内部口径 → D1/API 口径。`cut` 是本地对「14:50 固定口径」的旧叫法,对外一律叫 `formal`。
29
- * 未知口径直接抛错(宁可在同步这一步报出来,也不要往 D1 写第三种口径)。
29
+ * `intraday`(盘中滚动窗口近似)自 2026-09-22 起也合法入库(mode='intraday'),供报告查看页列出。
30
+ * 未知口径直接抛错(宁可在同步这一步报出来,也不要往 D1 写第四种口径)。
30
31
  */
31
- const D1_MODE = { cut: 'formal', formal: 'formal', observe: 'observe' };
32
+ const D1_MODE = { cut: 'formal', formal: 'formal', observe: 'observe', intraday: 'intraday' };
32
33
  function d1Mode(mode) {
33
34
  const m = D1_MODE[mode];
34
- if (!m) throw new Error(`未知尾盘口径「${mode}」:D1 只接受 formal / observe`);
35
+ if (!m) throw new Error(`未知尾盘口径「${mode}」:D1 只接受 formal / observe / intraday`);
35
36
  return m;
36
37
  }
37
38
 
@@ -256,8 +257,9 @@ async function applyTailFill(tradeDate, mode, patch, { argv = process.argv } = {
256
257
 
257
258
  /**
258
259
  * 清理历史遗留口径行:本地旧叫法 `cut` 曾被直接写进 D1(2026-09-21 的 14:50 正式运行),
259
- * 而 D1 / 网页 API 只认 formal / observe。补发前先删掉,否则同一天会出现
260
+ * 而 D1 / 网页 API 只认 formal / observe / intraday。补发前先删掉,否则同一天会出现
260
261
  * 「cut + formal」两条同义运行,记录页里那一天会重复一行。
262
+ * (2026-09-22 起 intraday 也是合法口径,不能一刀切删掉非 formal/observe 的行。)
261
263
  * 与 syncTailRun 同样作用于所有写入目标。
262
264
  * @param {{argv?: string[]}} [opts]
263
265
  * @returns {{results: Array<{name:string, file?:string, deleted?:Record<string,number|string>, error?:string}>, notes?:string[]}}
@@ -271,7 +273,7 @@ async function cleanupLegacyModes({ argv = process.argv } = {}) {
271
273
  const deleted = {};
272
274
  try {
273
275
  for (const tbl of ['tail_run', 'tail_pick']) {
274
- const r = await t.client.exec(`DELETE FROM ${tbl} WHERE mode NOT IN ('formal','observe')`);
276
+ const r = await t.client.exec(`DELETE FROM ${tbl} WHERE mode NOT IN ('formal','observe','intraday')`);
275
277
  const n = r && r.meta && r.meta.changes;
276
278
  // 远程 D1 会回报 meta.changes;本地 node:sqlite 的 exec 不回传,退回 'ok'
277
279
  deleted[tbl] = n == null ? 'ok' : n;
@@ -184,9 +184,10 @@ console.log('\n[9] 落库口径:内部口径必须翻译成 D1/API 的 formal
184
184
  check('cut → formal(14:50 固定口径的旧叫法)', d1Mode('cut') === 'formal', d1Mode('cut'));
185
185
  check('formal → formal(幂等,重跑不会写成别的)', d1Mode('formal') === 'formal');
186
186
  check('observe → observe', d1Mode('observe') === 'observe');
187
+ check('intraday → intraday(2026-09-22 起盘中版合法入库)', d1Mode('intraday') === 'intraday');
187
188
  let threw = false;
188
- try { d1Mode('intraday'); } catch (_) { threw = true; }
189
- check('未知口径直接抛错(绝不往 D1 写第三种口径)', threw);
189
+ try { d1Mode('bogus'); } catch (_) { threw = true; }
190
+ check('未知口径直接抛错(绝不往 D1 写第四种口径)', threw);
190
191
  }
191
192
 
192
193
  console.log('\n[10] 落库 SQL:列数 / 占位符数 / 参数数 必须三者一致,且列清单对齐 schema.sql');
@@ -39,7 +39,7 @@
39
39
  * 时段守卫(默认保留,别去掉):
40
40
  * 不加参数时,14:30 之前一律「未到尾盘观察时点」→ 跳过且不产出任何文件。
41
41
  * 这是为定时任务设计的护栏(避免上午空跑推送 0 候选)。要盘中随手看一眼,
42
- * 用 --intraday:它**自动**关闭 D1 同步与推送,并另存 eod-<日>-intraday.json,
42
+ * 用 --intraday:它**自动**关闭推送并另存 eod-<日>-intraday.json,
43
43
  * 绝不覆盖当天的正式口径文件(默认一天一个文件,多次运行记在 runs[];
44
44
  * 要完整对比某一时刻的 records 时再加 --stamp)。
45
45
  * ---------------------------------------------------------------------------
@@ -87,14 +87,13 @@ const OPT = {
87
87
  };
88
88
 
89
89
  /**
90
- * 盘中模式(--intraday)是**只读自查**用途:口径本身是滚动窗口近似,
91
- * 不是当日的正式尾盘结果。故强制关掉「外发」两条链路,避免把近似值
92
- * 当成正式数据写进 D1(网页 /api/tail/* 会读它)或推送到微信/邮箱。
93
- * 想改这个行为前先想清楚:D1 的 tail_run/tail_pick 只有 formal/observe 两种口径,
94
- * 盘中数据一旦写进去,复盘页会把它当成正式记录参与对比。
90
+ * 盘中模式(--intraday)是滚动窗口近似口径,不是当日正式尾盘结果。
91
+ * 推送链路仍然强制关闭(近似值不值得打扰微信/邮箱);
92
+ * D1 自 2026-09-22 起放开入库(mode='intraday',独立口径行,不会与 formal/observe 混淆),
93
+ * 报告查看页 /api/tail/runs 才能直接列出盘中版报告,不再靠前端探测文件是否存在。
94
+ * 显式 `--no-d1` 仍可临时关掉落库。
95
95
  */
96
96
  if (OPT.intraday) {
97
- OPT.noD1 = true;
98
97
  OPT.noNotify = true;
99
98
  }
100
99
 
package/gen_candidates.js CHANGED
@@ -48,6 +48,9 @@ const { now: nowStr, dayjs } = require('./time');
48
48
  const paths = require('./paths');
49
49
  // 首次运行脚手架:空工作目录时补齐目录与观察池(已存在则完全不动作)
50
50
  const scaffold = require('./scaffold');
51
+ // 规则引擎纯函数(R01/R07/R05/行业中位数/成交量单位/时区锚定)统一走 rules.js,
52
+ // 单测见 rules.test.js,避免与 gen_candidates 内联实现漂移导致双份真相。
53
+ const { lastClosedTradingDay, nextTradingDay, calcTurnover, computeSectors, calcR01, isLaggard, verifyR05 } = require('./rules');
51
54
 
52
55
  // ---------- 数据源:腾讯公开行情接口(无需任何第三方 CLI / 内置技能)----------
53
56
  const QT_QUOTE = 'https://qt.gtimg.cn/q=';
@@ -65,16 +68,7 @@ const KLINE_ENDPOINTS = [
65
68
  { name: 'web.ifzq/kline(不复权)', qfq: false, url: (c) => `https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=${c},day,,,260` },
66
69
  ];
67
70
 
68
- // ---------- R01 门槛常量 ----------
69
- const G = {
70
- VOL_RATIO_MIN: 1.5, // C1 量 ≥ 近5日均量 150%
71
- CHG_MIN: 3.0, // C3 涨幅下限
72
- CHG_MAX: 8.0, // C3 涨幅上限
73
- TURN_MIN: 3.0, // C4 换手 ≥ 3%
74
- CAP_MIN: 20, // C5 市值下限(亿)
75
- CAP_MAX: 500, // C5 市值上限(亿)
76
- HIGH52_ZONE: 0.9, // C6 收盘 < 0.9×52周高 视为未进入高位区
77
- };
71
+ // R01 门槛常量 G 已迁移至 rules.js(单一真相源,受 rules.test.js 守护)
78
72
 
79
73
  // 并发太高会触发腾讯接口反爬拦截(盘中尤其敏感)。6 并发下 377 只约 10~15s,
80
74
  // 兼顾速度与稳定;可用环境变量 CANDIDATE_CONCURRENCY 覆盖。
@@ -90,9 +84,7 @@ function log(...a) { process.stderr.write(a.join(' ') + '\n'); }
90
84
  // 交易日相关的小工具统一用 dayjs(北京时间),不用原生 Date:
91
85
  // 原生 Date 的 getDay()/getHours() 读的是「机器本地时区」,换台时区不是 +8 的机器
92
86
  // 就会把「15:00 前算盘中 / 周末顺延」判错;dayjs.tz 走显式时区,结果与机器设置无关。
93
- function ymd(d) {
94
- return dayjs.isDayjs(d) ? d.format('YYYY-MM-DD') : dayjs.tz(d).format('YYYY-MM-DD');
95
- }
87
+ // ymd 已随 lastClosedTradingDay/nextTradingDay 迁入 rules.js
96
88
  function parseArgs(argv) {
97
89
  const o = { date: null, limit: 0, out: null, quiet: false, offline: false, snapshot: null, dump: null, noNotify: false };
98
90
  for (let i = 0; i < argv.length; i++) {
@@ -112,26 +104,7 @@ function parseArgs(argv) {
112
104
  }
113
105
  return o;
114
106
  }
115
- function isWeekendD(d) { const g = d.day(); return g === 0 || g === 6; }
116
- /** dateStr(YYYY-MM-DD)之后的下一个交易日(仅剔除周末,不含节假日日历)。 */
117
- function nextTradingDay(dateStr) {
118
- let d = dayjs.tz(dateStr + ' 00:00:00').add(1, 'day');
119
- while (isWeekendD(d)) d = d.add(1, 'day');
120
- return ymd(d);
121
- }
122
- /** 最近一个「已收盘」的交易日:周六 → 周五,周日 → 周五,交易日 15:00 前 → 前一交易日。 */
123
- function lastClosedTradingDay(nowD) {
124
- const d = dayjs.isDayjs(nowD) ? nowD : dayjs.tz(nowD);
125
- const day = d.day();
126
- const hour = d.hour();
127
- let back = 0;
128
- if (day === 6) back = 1; // 周六 -> 周五
129
- else if (day === 0) back = 2; // 周日 -> 周五
130
- else if (hour < 15) back = 1; // 交易日盘中 -> 前一交易日
131
- let x = d.subtract(back, 'day');
132
- while (isWeekendD(x)) x = x.subtract(1, 'day');
133
- return ymd(x);
134
- }
107
+ // isWeekendD / nextTradingDay / lastClosedTradingDay 已迁入 rules.js(受 rules.test.js 守护)
135
108
 
136
109
  // ---------- HTTP 工具(腾讯公开行情接口,无需任何第三方 CLI)----------
137
110
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
@@ -269,23 +242,7 @@ async function fetchQuote(code) {
269
242
  return q;
270
243
  }
271
244
 
272
- // K 线成交量单位(2026-08 实测):
273
- // 主板/创业板 -> “手”(1手=100股);科创板 sh688/sh689 -> 直接就是“股”。
274
- // 例:金山办公 sh688111 于 2026-08-10 vol=9902896,按“手”折算成交额 2600 亿(荒谬),
275
- // 按“股”折算 26.01 亿、换手 2.13%(合理)。
276
- function volLotSize(code) {
277
- return /^sh68[89]/.test(String(code)) ? 1 : 100;
278
- }
279
-
280
- // 锚定日换手率 %:成交股数 / 流通股本 ×100
281
- // 报价里的 idx38 只反映“此刻”,盘前恒为 0 且与历史锚定日口径不符,故一律按 K 线量能推算。
282
- function calcTurnover(vol, circShares, code) {
283
- if (!vol || !circShares) return null;
284
- let t = (vol * volLotSize(code)) / circShares * 100;
285
- // 兜底:若单位判定失误会整整差 100 倍。A 股单日换手率极少超过 100%,此时自动纠偏。
286
- if (t > 100) t = t / 100;
287
- return t;
288
- }
245
+ // volLotSize / calcTurnover 已迁入 rules.js(受 rules.test.js 守护)
289
246
 
290
247
  // 分时(最新交易日),返回 [{ time, price, vol, amt }]
291
248
  async function fetchMinute(code) {
@@ -298,25 +255,7 @@ async function fetchMinute(code) {
298
255
  }).filter(r => r.time && !isNaN(r.price));
299
256
  }
300
257
 
301
- // R07 行业涨幅:观察池内同行业个股当日涨幅中位数(无需外部板块接口)
302
- function computeSectors(stocks) {
303
- const bySector = {};
304
- for (const s of stocks) {
305
- const name = (s.sector || '未分类');
306
- if (!bySector[name]) bySector[name] = { chgs: [], codes: [] };
307
- bySector[name].chgs.push(s.r01 && s.r01.chg != null ? s.r01.chg : 0);
308
- bySector[name].codes.push(s.code);
309
- }
310
- const list = Object.entries(bySector).map(([name, v]) => {
311
- const sorted = v.chgs.slice().sort((a, b) => a - b);
312
- const mid = sorted.length ? sorted[Math.floor((sorted.length - 1) / 2)] : 0;
313
- return { code: name, name, pct: mid };
314
- });
315
- list.sort((a, b) => b.pct - a.pct);
316
- const topN = Math.max(1, Math.round(list.length * 0.1));
317
- list.forEach((s, i) => { s.rank = i + 1; s.inTop10 = i < topN; s.total = list.length; s.topN = topN; s.members = null; });
318
- return list;
319
- }
258
+ // computeSectors 已迁入 rules.js(受 rules.test.js 守护)
320
259
  async function mapLimit(items, limit, fn) {
321
260
  const res = new Array(items.length);
322
261
  let i = 0;
@@ -445,46 +384,7 @@ async function main() {
445
384
  log(`[3/8] 计算 R01 量能验证突破 ...`);
446
385
  for (const s of stocks) {
447
386
  const k = s.kline;
448
- const r01 = { ok: false, gates: {}, core: 0, capOk: false, highZoneOk: true, mktCapYi: null, circCapYi: null, error: k.error || null };
449
- if (k && k.today && k.hist) {
450
- const t = k.today, hist = k.hist;
451
- // C1 量能倍数(不含当日)
452
- if (hist.length >= 5) {
453
- const ma5 = hist.slice(0, 5).reduce((a, r) => a + r.vol, 0) / 5;
454
- r01.volRatio = t.vol / ma5;
455
- r01.gates.C1 = t.vol >= G.VOL_RATIO_MIN * ma5;
456
- }
457
- // C2 突破前10日高(不含当日)
458
- if (hist.length >= 10) {
459
- const h10 = Math.max(...hist.slice(0, 10).map(r => r.high));
460
- r01.prior10High = h10;
461
- r01.gates.C2 = t.close > h10;
462
- }
463
- // C3 涨幅 3%-8%
464
- if (hist.length >= 1) {
465
- const prev = hist[0].close;
466
- r01.chg = (t.close / prev - 1) * 100;
467
- r01.gates.C3 = r01.chg >= G.CHG_MIN && r01.chg <= G.CHG_MAX;
468
- }
469
- // C4 换手
470
- r01.turn = t.turn;
471
- r01.gates.C4 = t.turn >= G.TURN_MIN;
472
- r01.close = t.close;
473
- // C5 市值
474
- if (s.quote && s.quote.total_market_cap) {
475
- r01.mktCapYi = +s.quote.total_market_cap / 1e8;
476
- r01.circCapYi = +s.quote.circulating_market_cap / 1e8;
477
- r01.capOk = r01.mktCapYi >= G.CAP_MIN && r01.mktCapYi <= G.CAP_MAX;
478
- }
479
- // C6 52周高位区
480
- if (s.quote && s.quote.high_52week && t.close) {
481
- r01.high52 = +s.quote.high_52week;
482
- r01.highZoneOk = t.close < G.HIGH52_ZONE * r01.high52;
483
- }
484
- r01.core = ['C1', 'C2', 'C3', 'C4'].filter(g => r01.gates[g]).length;
485
- r01.ok = r01.gates.C1 && r01.gates.C2 && r01.gates.C3 && r01.gates.C4 && r01.capOk && r01.highZoneOk;
486
- }
487
- s.r01 = r01;
387
+ s.r01 = calcR01(k && k.today, k && k.hist, s.quote, k && k.error);
488
388
  }
489
389
 
490
390
  // ---- 4. R07 行业涨幅 + 补涨映射(基于观察池内同行业个股涨幅中位数)----
@@ -495,7 +395,7 @@ async function main() {
495
395
  for (const s of stocks) {
496
396
  const sec = sectorByName[s.sector || '未分类'];
497
397
  if (sec) {
498
- const laggard = sec.inTop10 && (s.r01.chg != null) && (s.r01.chg < 0.5 * sec.pct);
398
+ const laggard = isLaggard(s.r01.chg, sec);
499
399
  s.r07 = { sectorName: sec.name, sectorPct: sec.pct, sectorRank: sec.rank, inTop10: sec.inTop10, laggard: !!laggard, total: sec.total, topN: sec.topN };
500
400
  } else {
501
401
  s.r07 = { sectorName: s.sector || '未分类', sectorPct: null, sectorRank: null, inTop10: false, laggard: false, total: validSectors.length, topN, unmatched: true };
@@ -506,29 +406,9 @@ async function main() {
506
406
  log(`[5/8] 核验 R05 尾盘异动(依赖分时数据)...`);
507
407
  for (const s of stocks) {
508
408
  const m = s.minute;
509
- const r05 = { available: false, lateGain: null, lateVolShare: null, fullDayGain: s.r01.chg, gates: {}, verdict: '', note: '' };
510
409
  let dayBars = [];
511
410
  if (Array.isArray(m)) dayBars = m.filter(b => b.date === anchor);
512
- if (dayBars.length === 0) {
513
- r05.verdict = 'gap';
514
- r05.note = `分时接口未回溯至锚定日 ${anchor}(仅保留最近约 5 个交易日),R05 三项核心门槛中依赖分时数据的两项无法验证,故判定为数据缺口,不编造信号。`;
515
- } else {
516
- r05.available = true;
517
- const late = dayBars.filter(b => b.time >= '1430');
518
- const p1430 = dayBars.find(b => b.time >= '1430') || dayBars[0];
519
- const p1500 = dayBars[dayBars.length - 1];
520
- if (p1430 && p1500 && p1430.price) r05.lateGain = (p1500.price / p1430.price - 1) * 100;
521
- const lateVol = late.reduce((a, b) => a + (b.vol || 0), 0);
522
- const dayVol = dayBars.reduce((a, b) => a + (b.vol || 0), 0);
523
- r05.lateVolShare = dayVol ? lateVol / dayVol : 0;
524
- r05.gates.lateGain = r05.lateGain != null && r05.lateGain >= 2;
525
- r05.gates.lateVol = r05.lateVolShare >= 0.20;
526
- r05.gates.fullDay = r05.fullDayGain != null && r05.fullDayGain < 7;
527
- // 净流入需 L2,分时数据不含 -> 不可得
528
- if (r05.gates.lateGain && r05.gates.lateVol && r05.gates.fullDay) r05.verdict = 'partial';
529
- else r05.verdict = 'no';
530
- }
531
- s.r05 = r05;
411
+ s.r05 = verifyR05(dayBars, s.r01.chg, anchor);
532
412
  }
533
413
 
534
414
  // ---- 6. 市场宽度 + 画像(无全市场公开源,标记为暂不可用)----
@@ -584,7 +464,10 @@ async function sendNotify({ anchor, target, breadth, build }) {
584
464
  // ===========================================================================
585
465
  // HTML 生成
586
466
  // ===========================================================================
587
- function b(zh, en) { return `<span data-zh="${zh}" data-en="${en}">${zh}</span>`; }
467
+ // 输出编码:所有外部数据(股票名/行业/动态字符串)写入 HTML 前必须转义,
468
+ // 防 API 名含 " < > 破坏结构或被注入。b() 与所有数据注入点统一走 esc()。
469
+ function esc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
470
+ function b(zh, en) { return `<span data-zh="${esc(zh)}" data-en="${esc(en)}">${esc(zh)}</span>`; }
588
471
  function num(n, d = 2) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(d); }
589
472
  function pct(n) { return (n == null || isNaN(n)) ? '—' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; }
590
473
  function yi(n) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(1) + ' 亿'; }
@@ -600,7 +483,7 @@ function buildHTML(m) {
600
483
 
601
484
  // 核心结论
602
485
  const best = [...universe].sort((a, b) => (b.r01.core || 0) - (a.r01.core || 0))[0];
603
- const bestStr = best && best.r01.core ? `${best.name}(${best.code.replace(/^[sh|sz]/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
486
+ const bestStr = best && best.r01.core ? `${esc(best.name)}(${best.code.replace(/^[sh|sz]/, '')}),四项中达成 ${best.r01.core} 项${best.r01.capOk ? ',市值达标' : ''}` : '无';
604
487
  const conclusion = [
605
488
  `<b>${b('R01 ' + (r01Triggers ? `硬触发 ${r01Triggers} 只` : '零硬触发'), 'R01 ' + (r01Triggers ? `${r01Triggers} hard trigger(s)` : 'zero hard triggers'))}。</b>` +
606
489
  (r01Triggers ? '' : `${universe.length} 只标的无一满足全部门槛。最接近者为 ${bestStr}。`),
@@ -614,14 +497,30 @@ function buildHTML(m) {
614
497
  'Under rule discipline, no name clearing all gates means an empty high-priority tier. Result: ' + secondary.length + ' secondary, ' + conditional.length + ' conditional, ' + excluded.length + ' excluded.')}`,
615
498
  ];
616
499
 
617
- // R01 矩阵
618
- let r01Rows = '';
500
+ // R01 + R07 全量判定矩阵(单表):两张全量表本是同一批标的,合并后行数减半;
501
+ // 每行带档位标记,配合顶部 chips 按档位筛选,避免「全量 377 行平铺找不到重点」。
502
+ const tierOf = new Map();
503
+ high.forEach(s => tierOf.set(s.code, 'high'));
504
+ secondary.forEach(s => tierOf.set(s.code, 'secondary'));
505
+ conditional.forEach(s => tierOf.set(s.code, 'conditional'));
506
+ excluded.forEach(s => tierOf.set(s.code, 'excluded'));
507
+ const TIER_LABEL = {
508
+ high: b('重点关注', 'high'), secondary: b('次级关注', 'secondary'),
509
+ conditional: b('条件观察', 'conditional'), excluded: b('排除', 'excluded'),
510
+ };
511
+ const tierCount = { high: high.length, secondary: secondary.length, conditional: conditional.length, excluded: excluded.length };
512
+ const tierChips = [`<button type="button" class="chip active" data-tier="">${b(`全部 ${universe.length}`, `All ${universe.length}`)}</button>`]
513
+ .concat(Object.keys(tierCount).map(k =>
514
+ `<button type="button" class="chip" data-tier="${k}">${TIER_LABEL[k]}<i>${tierCount[k]}</i></button>`)).join('');
515
+ let matrixRows = '';
619
516
  for (const s of universe) {
620
- const r = s.r01;
517
+ const r = s.r01, x = s.r07 || {};
518
+ const tier = tierOf.get(s.code) || 'excluded';
621
519
  const cls = r.ok ? ' class="hit"' : '';
622
- r01Rows += `<tr${cls}>` +
520
+ matrixRows += `<tr data-tier="${tier}"${cls}>` +
623
521
  `<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
624
522
  `<td>${b(s.name, s.name)}</td>` +
523
+ `<td><span class="tag tier-${tier}">${TIER_LABEL[tier]}</span></td>` +
625
524
  `<td>${num(r.close)}</td>` +
626
525
  `<td class="${r.chg >= 0 ? 'up' : 'down'}">${pct(r.chg)}</td>` +
627
526
  `<td>${num(r.turn)}%</td>` +
@@ -630,20 +529,10 @@ function buildHTML(m) {
630
529
  gateCell(r.gates.C1) + gateCell(r.gates.C2) + gateCell(r.gates.C3) + gateCell(r.gates.C4) +
631
530
  `<td><b>${r.core != null ? r.core + ' / 4' : '—'}</b></td>` +
632
531
  `<td>${r.mktCapYi != null ? yi(r.mktCapYi) : '—'}</td>` +
633
- `</tr>`;
634
- }
635
-
636
- // R07 表
637
- let r07Rows = '';
638
- for (const s of universe) {
639
- const x = s.r07 || {};
640
- r07Rows += `<tr>` +
641
- `<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
642
- `<td>${b(s.name, s.name)}</td>` +
643
532
  `<td>${x.sectorName ? b(x.sectorName, x.sectorName) : '—'}</td>` +
644
533
  `<td>${pct(x.sectorPct)}</td>` +
645
534
  `<td>${x.sectorRank ? x.sectorRank + '/' + x.total : '—'}</td>` +
646
- `<td>${x.inTop10 ? '<span class="yes">★强势</span>' : '<span class="no">—</span>'}</td>` +
535
+ `<td>${x.inTop10 ? '<span class="yes">★</span>' : '<span class="no">—</span>'}</td>` +
647
536
  `<td>${x.laggard ? '<span class="yes">补涨</span>' : '<span class="no">领涨</span>'}</td>` +
648
537
  `</tr>`;
649
538
  }
@@ -738,10 +627,29 @@ function buildHTML(m) {
738
627
  );
739
628
 
740
629
  // 组装
741
- const css = ` :root {
630
+ const css = ` :root, :root[data-theme="light"] {
742
631
  --bg: #fffbeb; --card: #fff; --text: #1c1917; --muted: #92400e;
743
632
  --border: #fde68a; --accent: #b45309; --red: #dc2626; --green: #16a34a;
744
633
  --blue: #2563eb; --amber: #d97706; --gold: #ca8a04; --slate: #64748b;
634
+ --btn-bg: #fff; --btn-fg: #b45309;
635
+ --callout-bg: #fff7ed;
636
+ --th-bg: #fefce8;
637
+ --tag-bg: #eff6ff; --tag-fail-bg: #f1f5f9; --tag-pass-bg: #fef2f2; --tag-gap-bg: #fffbeb;
638
+ --score-bg: #fffbeb;
639
+ --disc-bg: #fef2f2; --disc-border: #fecaca; --disc-fg: #991b1b;
640
+ --card-empty-bg: #fafaf9; --hit-bg: #fffdf5; --note-bg: #f8fafc; --risk-bg: #fef2f2;
641
+ }
642
+ :root[data-theme="dark"] {
643
+ --bg: #0f1115; --card: #181b21; --text: #e7e9ec; --muted: #c9a684;
644
+ --border: #343a44; --accent: #f59e0b; --red: #f87171; --green: #4ade80;
645
+ --blue: #60a5fa; --amber: #fbbf24; --gold: #fbbf24; --slate: #94a3b8;
646
+ --btn-bg: #1f2329; --btn-fg: #f59e0b;
647
+ --callout-bg: #1f1c17;
648
+ --th-bg: #1d2128;
649
+ --tag-bg: #1b2533; --tag-fail-bg: #232831; --tag-pass-bg: #2a1e1e; --tag-gap-bg: #1f1c17;
650
+ --score-bg: #211d14;
651
+ --disc-bg: #2a1e1e; --disc-border: #5b2a2a; --disc-fg: #fca5a5;
652
+ --card-empty-bg: #14171c; --hit-bg: #20242b; --note-bg: #1b1f26; --risk-bg: #2a1e1e;
745
653
  }
746
654
  * { box-sizing: border-box; margin: 0; padding: 0; }
747
655
  body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 24px 16px; }
@@ -751,8 +659,8 @@ function buildHTML(m) {
751
659
  h2 { font-size: 1.15rem; font-weight: 600; margin: 30px 0 12px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
752
660
  h3 { font-size: 1.05rem; font-weight: 600; margin-bottom: 10px; }
753
661
  .sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 14px; }
754
- #langBtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: #fff; color: var(--accent); font-weight: 600; font-size: 0.85rem; padding: 7px 16px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
755
- #langBtn:hover { background: var(--accent); color: #fff; }
662
+ .topbtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: var(--btn-bg); color: var(--btn-fg); font-weight: 600; font-size: 0.85rem; padding: 7px 16px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
663
+ .topbtn:hover { background: var(--accent); color: var(--btn-bg); }
756
664
  .meta-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 0.88rem; margin-bottom: 18px; }
757
665
  .meta-bar .label { color: var(--muted); }
758
666
  .meta-bar .value { font-weight: 600; }
@@ -791,10 +699,34 @@ function buildHTML(m) {
791
699
  .na { color: var(--amber); }
792
700
  .src { font-size: 0.78rem; color: var(--slate); margin-top: 8px; font-style: normal; }
793
701
  .disclaimer { margin-top: 26px; padding: 14px 18px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; font-size: 0.85rem; color: #991b1b; }
794
- .footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }`;
702
+ .footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }
703
+ /* 暗黑主题:覆盖硬编码组件色(默认 data-theme="dark",见 <html>) */
704
+ :root[data-theme="dark"] .card.empty { background: var(--card-empty-bg); }
705
+ :root[data-theme="dark"] .callout { background: var(--callout-bg); }
706
+ :root[data-theme="dark"] .score { background: var(--score-bg); color: var(--gold); }
707
+ :root[data-theme="dark"] .tag { background: var(--tag-bg); }
708
+ :root[data-theme="dark"] .tag.fail { background: var(--tag-fail-bg); }
709
+ :root[data-theme="dark"] .tag.pass { background: var(--tag-pass-bg); }
710
+ :root[data-theme="dark"] .tag.gap { background: var(--tag-gap-bg); }
711
+ :root[data-theme="dark"] .risk { background: var(--risk-bg); }
712
+ :root[data-theme="dark"] .note { background: var(--note-bg); }
713
+ :root[data-theme="dark"] tr.hit { background: var(--hit-bg); }
714
+ :root[data-theme="dark"] th { background: var(--th-bg); }
715
+ :root[data-theme="dark"] .disclaimer { background: var(--disc-bg); border-color: var(--disc-border); color: var(--disc-fg); }
716
+ /* R01+R07 合并矩阵工具条:档位 chips 筛选(与 eod 报告同款交互) */
717
+ .toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 0 0 12px; }
718
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
719
+ .chip { border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); font: inherit; font-size: 0.8rem; padding: 3px 11px; border-radius: 999px; cursor: pointer; }
720
+ .chip i { font-style: normal; color: var(--muted); margin-left: 3px; }
721
+ .chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
722
+ .chip.active i { color: inherit; opacity: 0.85; }
723
+ .tag.tier-high { background: var(--tag-pass-bg); color: var(--red); }
724
+ .tag.tier-secondary { background: var(--tag-bg); color: var(--blue); }
725
+ .tag.tier-conditional { background: var(--tag-gap-bg); color: var(--amber); }
726
+ .tag.tier-excluded { background: var(--tag-fail-bg); color: var(--slate); }`;
795
727
 
796
728
  return `<!DOCTYPE html>
797
- <html lang="zh-CN">
729
+ <html lang="zh-CN" data-theme="dark">
798
730
  <head>
799
731
  <meta charset="UTF-8">
800
732
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -808,7 +740,8 @@ function buildHTML(m) {
808
740
  <h1>${b(`A股次日候选池 — ${anchor} 收盘后初筛`, `A-Share Next-Day Candidate Pool — Post-Close Screening, ${anchor}`)}</h1>
809
741
  <div class="sub">${b(`候选起点:candidates.json 可编辑观察池(${limit} 只上限,锚定 ${anchor}) · 规则:R01 量能验证突破 + R07 板块内补涨 · 面向 ${target} 交易日`, `Starting universe: editable candidates.json watchlist (cap ${limit}, anchored to ${anchor}) · Rules: R01 Volume-Confirmed Breakout + R07 Intra-Sector Laggard · For the ${target} session`)}</div>
810
742
  </div>
811
- <button id="langBtn" type="button">EN</button>
743
+ <button id="themeBtn" type="button" class="topbtn" title="切换深浅色">🌙</button>
744
+ <button id="langBtn" type="button" class="topbtn">EN</button>
812
745
  </div>
813
746
 
814
747
  <div class="meta-bar">
@@ -829,31 +762,21 @@ function buildHTML(m) {
829
762
  <p class="src">${b('数据来源:腾讯公开行情接口(web.ifzq.gtimg.cn 日线/分时、qt.gtimg.cn 报价/市值/52周高/换手),数据时点 ' + anchor + ' 收盘。', 'Source: Tencent public market-data endpoints (web.ifzq.gtimg.cn for daily/minute, qt.gtimg.cn for quote/cap/52w-high/turnover). Data timestamp: ' + anchor + ' close.')}</p>
830
763
  </div>
831
764
 
832
- <h2>${b('R01 量能验证突破 — 全量判定明细', 'R01 Volume-Confirmed Breakout — Full Evaluation Matrix')}</h2>
765
+ <h2>${b('R01 + R07 全量判定矩阵', 'R01 + R07 Full Evaluation Matrix')}</h2>
833
766
  <div class="card">
834
- <p class="body-text" style="margin-bottom:12px">${b('四项门槛:C1 当日量 ≥ 近 5 日均量 150% | C2 收盘突破近 10 日最高价 | C3 涨幅 3%–8% | C4 换手率 ≥ 3%。红色为达成。', 'Four gates: C1 volume at 150%+ of the 5-day average | C2 close above the prior 10-day high | C3 gain 3%–8% | C4 turnover 3%+. Red marks a pass.')}</p>
835
- <div class="table-wrap"><table>
767
+ <p class="body-text" style="margin-bottom:12px">${b('四项门槛:C1 当日量 ≥ 近 5 日均量 150% | C2 收盘突破近 10 日最高价 | C3 涨幅 3%–8% | C4 换手率 ≥ 3%。红色为达成。R07:所属行业居前 10% 且个股涨幅 < 行业涨幅一半记为补涨。点上方档位 chips 可只看某一档。', 'Four gates: C1 volume at 150%+ of the 5-day average | C2 close above the prior 10-day high | C3 gain 3%–8% | C4 turnover 3%+. Red marks a pass. R07 laggard: sector in the top 10% and gain below half the sector median. Use the tier chips above to focus on one tier.')}</p>
768
+ <div class="toolbar"><div class="chips" id="tierChips">${tierChips}</div></div>
769
+ <div class="table-wrap"><table id="matrixTable">
836
770
  <thead><tr>
837
- <th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('收盘', 'Close')}</th>
838
- <th>${b('涨幅', 'Chg')}</th><th>${b('换手', 'Turn.')}</th><th>${b('量能/5日', 'Vol/5d')}</th>
839
- <th>${b('前10日高', 'Prior 10d high')}</th>
771
+ <th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('档位', 'Tier')}</th>
772
+ <th>${b('收盘', 'Close')}</th><th>${b('涨幅', 'Chg')}</th><th>${b('换手', 'Turn.')}</th>
773
+ <th>${b('量能/5日', 'Vol/5d')}</th><th>${b('前10日高', 'Prior 10d high')}</th>
840
774
  <th>C1</th><th>C2</th><th>C3</th><th>C4</th>
841
775
  <th>${b('达成', 'Score')}</th><th>${b('总市值', 'Mkt cap')}</th>
776
+ <th>${b('所属行业', 'Sector')}</th><th>${b('行业涨幅', 'Sector chg')}</th>
777
+ <th>${b('行业排名', 'Rank')}</th><th>${b('强势', 'Strong?')}</th><th>${b('角色', 'Role')}</th>
842
778
  </tr></thead>
843
- <tbody>${r01Rows}</tbody>
844
- </table></div>
845
- </div>
846
-
847
- <h2>${b('R07 板块内补涨 — 行业归属与强弱', 'R07 Intra-Sector Laggard — Sector Mapping & Strength')}</h2>
848
- <div class="card">
849
- <p class="body-text" style="margin-bottom:12px">${b(`行业口径为观察池内行业分组(共 ${m.sectors ? m.sectors.length : '—'} 个,前 10% = 前 ${m.topN} 名)。补涨判定:所属行业居前 10% 且 个股涨幅 < 行业涨幅的一半。`, `Sector universe is the watchlist's industry groups (${m.sectors ? m.sectors.length : '—'} sectors, top 10% = top ${m.topN}). Laggard test: the stock's sector is in the top 10% and its gain is below half the sector's gain.`)}</p>
850
- <div class="table-wrap"><table>
851
- <thead><tr>
852
- <th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('所属行业', 'Sector')}</th>
853
- <th>${b('行业涨幅', 'Sector chg')}</th><th>${b('行业排名', 'Sector rank')}</th>
854
- <th>${b('强势板块', 'Strong?')}</th><th>${b('角色', 'Role')}</th>
855
- </tr></thead>
856
- <tbody>${r07Rows}</tbody>
779
+ <tbody>${matrixRows}</tbody>
857
780
  </table></div>
858
781
  </div>
859
782
 
@@ -868,10 +791,12 @@ function buildHTML(m) {
868
791
 
869
792
  <h2>${b('排除说明', 'Exclusions')}</h2>
870
793
  <div class="card">
794
+ <details><summary>${b(`排除明细(${excluded.length} 条,点击展开)`, `Exclusion details (${excluded.length}, click to expand)`)}</summary>
871
795
  <div class="table-wrap"><table>
872
796
  <thead><tr><th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('排除原因', 'Reason')}</th></tr></thead>
873
797
  <tbody>${exRows}</tbody>
874
798
  </table></div>
799
+ </details>
875
800
  </div>
876
801
 
877
802
  <h2>${b('R05 尾盘异动核验', 'R05 Late-Session Capital Surge')}</h2>
@@ -896,6 +821,23 @@ function buildHTML(m) {
896
821
 
897
822
  <script>
898
823
  (function () {
824
+ // 主题切换:默认暗色(<html data-theme="dark">),选择持久化到 localStorage
825
+ var themeBtn = document.getElementById('themeBtn');
826
+ var root = document.documentElement;
827
+ var savedTheme = null;
828
+ try { savedTheme = localStorage.getItem('report-theme'); } catch (e) {}
829
+ if (savedTheme === 'light' || savedTheme === 'dark') root.setAttribute('data-theme', savedTheme);
830
+ function syncThemeLabel() {
831
+ var t = root.getAttribute('data-theme');
832
+ themeBtn.textContent = (t === 'dark') ? '☀' : '🌙';
833
+ }
834
+ syncThemeLabel();
835
+ themeBtn.addEventListener('click', function () {
836
+ var t = (root.getAttribute('data-theme') === 'dark') ? 'light' : 'dark';
837
+ root.setAttribute('data-theme', t);
838
+ try { localStorage.setItem('report-theme', t); } catch (e) {}
839
+ syncThemeLabel();
840
+ });
899
841
  var btn = document.getElementById('langBtn');
900
842
  var lang = 'zh';
901
843
  var nodes = document.querySelectorAll('[data-zh]');
@@ -915,6 +857,23 @@ function buildHTML(m) {
915
857
  }
916
858
  btn.addEventListener('click', function () { lang = (lang === 'zh') ? 'en' : 'zh'; apply(lang); });
917
859
  apply(lang);
860
+
861
+ // R01+R07 矩阵:档位 chips 筛选(display 切换,不重排——全量行顺序保持观察池原序)
862
+ var matrixBody = document.querySelector('#matrixTable tbody');
863
+ var tierChips = document.getElementById('tierChips');
864
+ if (matrixBody && tierChips) {
865
+ tierChips.addEventListener('click', function (e) {
866
+ var chip = e.target.closest('.chip');
867
+ if (!chip) return;
868
+ var tier = chip.getAttribute('data-tier') || '';
869
+ Array.prototype.forEach.call(tierChips.querySelectorAll('.chip'), function (c) {
870
+ c.classList.toggle('active', (c.getAttribute('data-tier') || '') === tier);
871
+ });
872
+ Array.prototype.forEach.call(matrixBody.querySelectorAll('tr[data-tier]'), function (tr) {
873
+ tr.style.display = (!tier || tr.getAttribute('data-tier') === tier) ? '' : 'none';
874
+ });
875
+ });
876
+ }
918
877
  })();
919
878
  </script>
920
879
  </body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candidate-pool",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
5
  "bin": {
6
6
  "candidate-pool": "bin/candidate-pool.js",