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 +88 -0
- package/db/schema.sql +2 -2
- package/eod/build_industry_map.js +133 -13
- package/eod/lib/report.js +181 -36
- package/eod/lib/store.js +1 -0
- package/eod/lib/store_d1.js +8 -6
- package/eod/lib/trading.selftest.js +3 -2
- package/eod/tail_screener.js +6 -7
- package/gen_candidates.js +133 -174
- package/package.json +1 -1
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 --
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
113
|
-
|
|
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('
|
|
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 & 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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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="
|
|
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(`
|
|
277
|
-
|
|
278
|
-
|
|
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
package/eod/lib/store_d1.js
CHANGED
|
@@ -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
|
-
*
|
|
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('
|
|
189
|
-
check('未知口径直接抛错(绝不往 D1
|
|
189
|
+
try { d1Mode('bogus'); } catch (_) { threw = true; }
|
|
190
|
+
check('未知口径直接抛错(绝不往 D1 写第四种口径)', threw);
|
|
190
191
|
}
|
|
191
192
|
|
|
192
193
|
console.log('\n[10] 落库 SQL:列数 / 占位符数 / 参数数 必须三者一致,且列清单对齐 schema.sql');
|
package/eod/tail_screener.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* 时段守卫(默认保留,别去掉):
|
|
40
40
|
* 不加参数时,14:30 之前一律「未到尾盘观察时点」→ 跳过且不产出任何文件。
|
|
41
41
|
* 这是为定时任务设计的护栏(避免上午空跑推送 0 候选)。要盘中随手看一眼,
|
|
42
|
-
* 用 --intraday
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
467
|
+
// 输出编码:所有外部数据(股票名/行业/动态字符串)写入 HTML 前必须转义,
|
|
468
|
+
// 防 API 名含 " < > 破坏结构或被注入。b() 与所有数据注入点统一走 esc()。
|
|
469
|
+
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
755
|
-
|
|
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="
|
|
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
|
|
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="
|
|
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('
|
|
838
|
-
<th>${b('
|
|
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>${
|
|
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>
|