candidate-pool 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +245 -0
- package/README.md +397 -0
- package/bin/candidate-pool.js +23 -0
- package/bin/tail-screener.js +22 -0
- package/build_universe.js +231 -0
- package/candidates.example.json +269 -0
- package/db/.env.example +13 -0
- package/db/backfill.js +71 -0
- package/db/clients.js +48 -0
- package/db/d1client.js +69 -0
- package/db/ddl.js +82 -0
- package/db/diff.js +223 -0
- package/db/diff_selftest.js +124 -0
- package/db/enrich_stock_base.js +138 -0
- package/db/migrate.js +177 -0
- package/db/normalize.js +129 -0
- package/db/persist.js +90 -0
- package/db/query_local.js +43 -0
- package/db/schema.sql +225 -0
- package/db/sqlite_client.js +81 -0
- package/db/verify_sync.js +107 -0
- package/db/write_live.js +54 -0
- package/eod/build_industry_map.js +146 -0
- package/eod/data/industry-map.json +1 -0
- package/eod/lib/http.js +188 -0
- package/eod/lib/market.js +345 -0
- package/eod/lib/report.js +329 -0
- package/eod/lib/screen.js +355 -0
- package/eod/lib/sector.js +96 -0
- package/eod/lib/store.js +204 -0
- package/eod/lib/store_d1.js +293 -0
- package/eod/lib/trading.js +252 -0
- package/eod/lib/trading.selftest.js +283 -0
- package/eod/tail.config.js +212 -0
- package/eod/tail_screener.js +373 -0
- package/gen_candidates.js +994 -0
- package/notify.js +236 -0
- package/notify_config.example.json +14 -0
- package/package.json +96 -0
- package/paths.js +127 -0
- package/paths.selftest.js +142 -0
- package/scaffold.js +199 -0
- package/scaffold.selftest.js +153 -0
- package/time.js +17 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/*
|
|
4
|
+
* gen_candidates.js
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* A股短线交易 — 次日候选池初筛(自动生成版)
|
|
7
|
+
* 复刻 A股短线交易 skill 的「标的初筛」流程:
|
|
8
|
+
* 候选起点 = 本目录 candidates.json 配置的可编辑观察池(覆盖主要行业,可自由增删)
|
|
9
|
+
* 规则组合 = R01 量能验证突破 + R07 板块内补涨(+ R05 尾盘异动核验)
|
|
10
|
+
* 输出 = 单文件、资源全内联、无外链的双语 HTML(右上角中英切换)
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node gen_candidates.js # 在线:锚定日 = 最近一个已收盘交易日
|
|
14
|
+
* node gen_candidates.js --date 2026-08-07
|
|
15
|
+
* node gen_candidates.js --limit 20 --out report.html --quiet
|
|
16
|
+
* node gen_candidates.js --date 2026-08-07 --dump data/snapshot-2026-08-07.json # 在线抓取后导出快照到指定路径
|
|
17
|
+
* node gen_candidates.js --offline --snapshot data/snapshot-2026-08-07.json # 离线:从快照重建报告
|
|
18
|
+
*
|
|
19
|
+
* 输出(默认按锚定日留档,覆盖式写入,每天一份):
|
|
20
|
+
* 报告: reports/stock_list_<锚定日>.html
|
|
21
|
+
* 快照: data/snapshot-<锚定日>.json (实时运行自动写出,供日后离线复现)
|
|
22
|
+
*
|
|
23
|
+
* 推送: 实时运行结束后自动把结果推送到 notify_config.json 配置的渠道
|
|
24
|
+
* (个人微信 Server酱/PushPlus + 163 邮箱)。可用 --no-notify 关闭。
|
|
25
|
+
*
|
|
26
|
+
* 工作目录(相对路径的落点,统一由 paths.js 解析):
|
|
27
|
+
* 默认 = 本仓库根(与历史行为一致)。**装成 npm 包 / 从别处调用时必须重定位**,
|
|
28
|
+
* 否则产物会写进 node_modules 里那个包目录。两种覆盖方式:
|
|
29
|
+
* --cwd <dir> 或 CANDIDATE_POOL_HOME=<dir>
|
|
30
|
+
* HOME 下保持相同布局:candidates.json / data/ / reports/ / notify_config.json
|
|
31
|
+
* (尾盘相关的 eod/data、eod/reports 同理)。`--paths` 可打印实际解析结果。
|
|
32
|
+
*
|
|
33
|
+
* 数据源: 腾讯公开行情接口(无需任何第三方 CLI / 内置技能):
|
|
34
|
+
* - 实时报价 / 总市值 / 流通市值 / 52周高 / 换手率: qt.gtimg.cn
|
|
35
|
+
* - 日K线(前复权) / 分时: web.ifzq.gtimg.cn/appstock/app
|
|
36
|
+
* 原 westock 内置技能在本机安装目录变更后已不可用,故改为直连上述公开接口;
|
|
37
|
+
* R01/R07/R05 判定逻辑、HTML、离线模式均保持不变。
|
|
38
|
+
* 离线模式不依赖任何外部网络,可在任意装了 Node >=18 的环境运行(GitHub Actions 等)。
|
|
39
|
+
* ---------------------------------------------------------------------------
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const https = require('https');
|
|
45
|
+
const { now: nowStr, dayjs } = require('./time');
|
|
46
|
+
// 工作目录解析:产物/配置的落点走 paths,代码资产(本文件等)仍锚在包内。
|
|
47
|
+
// 默认 HOME = 包根(与历史 __dirname 行为一致);可用 CANDIDATE_POOL_HOME / --cwd 重定位。
|
|
48
|
+
const paths = require('./paths');
|
|
49
|
+
// 首次运行脚手架:空工作目录时补齐目录与观察池(已存在则完全不动作)
|
|
50
|
+
const scaffold = require('./scaffold');
|
|
51
|
+
|
|
52
|
+
// ---------- 数据源:腾讯公开行情接口(无需任何第三方 CLI / 内置技能)----------
|
|
53
|
+
const QT_QUOTE = 'https://qt.gtimg.cn/q=';
|
|
54
|
+
const IFZQ = 'https://web.ifzq.gtimg.cn/appstock/app';
|
|
55
|
+
|
|
56
|
+
// 日K线故障转移链。腾讯的 WAF 是【按接口路径】限流的:高频拉取会让
|
|
57
|
+
// web.ifzq.gtimg.cn 的 fqkline 路径返回 HTTP 501 拦截页,而同主机的其他路径、
|
|
58
|
+
// 以及镜像域名仍然正常。所以按顺序逐个降级,能显著提升可用性。
|
|
59
|
+
// 实测(2026-08-11 盘中,web.ifzq 已被封时):后三条均返回 200。
|
|
60
|
+
const KLINE_ENDPOINTS = [
|
|
61
|
+
{ name: 'web.ifzq/fqkline', qfq: true, url: (c) => `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
62
|
+
{ name: 'ifzq/fqkline', qfq: true, url: (c) => `https://ifzq.gtimg.cn/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
63
|
+
{ name: 'proxy.finance/fqkline', qfq: true, url: (c) => `https://proxy.finance.qq.com/ifzqgtimg/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
64
|
+
// 最后兜底:不复权。数据仍可用,但除权日附近的涨幅/均量会失真,报告中会标注。
|
|
65
|
+
{ name: 'web.ifzq/kline(不复权)', qfq: false, url: (c) => `https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=${c},day,,,260` },
|
|
66
|
+
];
|
|
67
|
+
|
|
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
|
+
};
|
|
78
|
+
|
|
79
|
+
// 并发太高会触发腾讯接口反爬拦截(盘中尤其敏感)。6 并发下 377 只约 10~15s,
|
|
80
|
+
// 兼顾速度与稳定;可用环境变量 CANDIDATE_CONCURRENCY 覆盖。
|
|
81
|
+
const CONCURRENCY = Number(process.env.CANDIDATE_CONCURRENCY) || 6;
|
|
82
|
+
|
|
83
|
+
// 数据健康度红线:K线获取失败率超过该比例即判定为"数据源不可用",
|
|
84
|
+
// 直接中止并以非零码退出,绝不生成空报告、更不推送。
|
|
85
|
+
// 若没有这道闸门,一次全量拦截会静默产出"今日 0 触发",看起来完全正常。
|
|
86
|
+
const MAX_FAIL_RATE = Number(process.env.CANDIDATE_MAX_FAIL_RATE) || 0.2;
|
|
87
|
+
|
|
88
|
+
// ---------- 小工具 ----------
|
|
89
|
+
function log(...a) { process.stderr.write(a.join(' ') + '\n'); }
|
|
90
|
+
// 交易日相关的小工具统一用 dayjs(北京时间),不用原生 Date:
|
|
91
|
+
// 原生 Date 的 getDay()/getHours() 读的是「机器本地时区」,换台时区不是 +8 的机器
|
|
92
|
+
// 就会把「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
|
+
}
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const o = { date: null, limit: 0, out: null, quiet: false, offline: false, snapshot: null, dump: null, noNotify: false };
|
|
98
|
+
for (let i = 0; i < argv.length; i++) {
|
|
99
|
+
const a = argv[i];
|
|
100
|
+
if (a === '--date') o.date = argv[++i];
|
|
101
|
+
else if (a === '--limit') o.limit = parseInt(argv[++i], 10);
|
|
102
|
+
else if (a === '--out') o.out = argv[++i];
|
|
103
|
+
else if (a === '--quiet') o.quiet = true;
|
|
104
|
+
else if (a === '--offline') o.offline = true;
|
|
105
|
+
else if (a === '--snapshot') o.snapshot = argv[++i];
|
|
106
|
+
else if (a === '--dump') o.dump = argv[++i];
|
|
107
|
+
else if (a === '--no-notify') o.noNotify = true;
|
|
108
|
+
else if (a === '--print-anchor') o.printAnchor = true;
|
|
109
|
+
else if (a === '--paths') o.paths = true;
|
|
110
|
+
else if (a === '--cwd') { i++; } // 由 paths.js 统一解析,这里只吃掉它的值
|
|
111
|
+
else if (a === '-h' || a === '--help') { o.help = true; }
|
|
112
|
+
}
|
|
113
|
+
return o;
|
|
114
|
+
}
|
|
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
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------- HTTP 工具(腾讯公开行情接口,无需任何第三方 CLI)----------
|
|
137
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
138
|
+
|
|
139
|
+
// 腾讯接口在短时间高频请求(尤其盘中)会返回一张 WAF 反爬跳转页而非 JSON:
|
|
140
|
+
// <!DOCTYPE html><html><head><script>var i=location.href;var v=window.btoa? ...
|
|
141
|
+
// 它是 HTTP 200,靠 JSON.parse 报错才能发现。必须单独识别并用【长退避】重试,
|
|
142
|
+
// 短退避(几百毫秒)拿到的仍是同一张拦截页,等于白重试。
|
|
143
|
+
function looksLikeWaf(txt) {
|
|
144
|
+
const head = String(txt).slice(0, 300);
|
|
145
|
+
return /<!DOCTYPE html|<html[\s>]/i.test(head) && /window\.btoa|location\.href/i.test(head);
|
|
146
|
+
}
|
|
147
|
+
// 完整的浏览器 UA。残缺 UA(如裸 "Mozilla/5.0")本身就是风控指纹特征。
|
|
148
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
|
149
|
+
const WAF_BACKOFF = [2000, 5000, 12000, 25000]; // 命中拦截时的退避梯度(ms)
|
|
150
|
+
|
|
151
|
+
function httpGet(url, { json = true, retries = 4, timeout = 15000 } = {}) {
|
|
152
|
+
return new Promise(async (resolve, reject) => {
|
|
153
|
+
let lastErr;
|
|
154
|
+
let wafHits = 0;
|
|
155
|
+
for (let i = 0; i < retries; i++) {
|
|
156
|
+
if (i > 0) {
|
|
157
|
+
// 拦截用长退避,普通网络错误用短退避
|
|
158
|
+
await sleep(lastErr && lastErr.waf
|
|
159
|
+
? WAF_BACKOFF[Math.min(wafHits - 1, WAF_BACKOFF.length - 1)]
|
|
160
|
+
: 500 * i);
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const { status, txt } = await new Promise((res, rej) => {
|
|
164
|
+
const req = https.get(url, { timeout, headers: { 'User-Agent': UA, 'Referer': 'https://gu.qq.com/', 'Accept': '*/*' } }, (resp) => {
|
|
165
|
+
let d = ''; resp.on('data', c => d += c); resp.on('end', () => res({ status: resp.statusCode, txt: d.trim() }));
|
|
166
|
+
});
|
|
167
|
+
req.on('error', rej);
|
|
168
|
+
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
169
|
+
});
|
|
170
|
+
// WAF 拦截返回 HTTP 501 + 跳转页;状态码判定比嗅探 body 更可靠,两者都查。
|
|
171
|
+
if (status === 501 || looksLikeWaf(txt)) {
|
|
172
|
+
wafHits++;
|
|
173
|
+
const e = new Error(`被接口反爬拦截(HTTP ${status})`);
|
|
174
|
+
e.waf = true;
|
|
175
|
+
throw e;
|
|
176
|
+
}
|
|
177
|
+
if (status && status >= 400) throw new Error(`HTTP ${status}`);
|
|
178
|
+
if (!json) return resolve(txt);
|
|
179
|
+
try { return resolve(JSON.parse(txt)); }
|
|
180
|
+
catch (e) {
|
|
181
|
+
const m = txt.match(/^[a-zA-Z0-9_]+\(([\s\S]*)\);?\s*$/);
|
|
182
|
+
if (m) { try { return resolve(JSON.parse(m[1])); } catch (e2) {} }
|
|
183
|
+
throw new Error('JSON 解析失败: ' + txt.slice(0, 120));
|
|
184
|
+
}
|
|
185
|
+
} catch (e) { lastErr = e; }
|
|
186
|
+
}
|
|
187
|
+
reject(lastErr || new Error('httpGet 失败'));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 候选源:HOME 下 candidates.json 配置的可编辑观察池(替代 westock ranking CompScore)
|
|
192
|
+
function loadUniverse(limit) {
|
|
193
|
+
const file = paths.candidatesFile();
|
|
194
|
+
if (!fs.existsSync(file)) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`未找到候选观察池 ${file}\n`
|
|
197
|
+
+ ` 当前工作目录:${paths.home()}(来源 ${paths.homeSource()})\n`
|
|
198
|
+
+ ` 请在该目录创建 candidates.json(结构见仓库内 candidates.json / candidates.example.json),`
|
|
199
|
+
+ `或设置 CANDIDATE_POOL_HOME / --cwd 指向已有工作目录。`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
203
|
+
const list = (cfg.stocks || []).map(s => ({ code: String(s.code), name: String(s.name || s.code), sector: String(s.sector || '未分类') }));
|
|
204
|
+
if (!list.length) throw new Error('candidates.json 的 stocks 为空,请配置候选观察池');
|
|
205
|
+
return (limit && limit > 0) ? list.slice(0, limit) : list;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 日K线(前复权 qfq),返回 { rows, today, hist }
|
|
209
|
+
// 注意:腾讯接口按日期【升序】返回(最旧在前),此处统一翻转为【降序】(最新在前),
|
|
210
|
+
// 与下游 hist[0]=前一交易日 / hist.slice(0,5)=最近5日 的口径保持一致。
|
|
211
|
+
// 粘性指针:记住当前可用的 K线端点。某条链路被 WAF 封掉后,指针前移,
|
|
212
|
+
// 后续标的直接走可用端点,不必每只票都去撞一次已知的墙。
|
|
213
|
+
let _klineEp = 0;
|
|
214
|
+
|
|
215
|
+
async function fetchKline(code, anchor) {
|
|
216
|
+
let lastErr;
|
|
217
|
+
for (let step = 0; step < KLINE_ENDPOINTS.length; step++) {
|
|
218
|
+
const idx = (_klineEp + step) % KLINE_ENDPOINTS.length;
|
|
219
|
+
const ep = KLINE_ENDPOINTS[idx];
|
|
220
|
+
let obj;
|
|
221
|
+
try {
|
|
222
|
+
obj = await httpGet(ep.url(code), { retries: 2 });
|
|
223
|
+
} catch (e) {
|
|
224
|
+
lastErr = e;
|
|
225
|
+
if (e.waf || /timeout|ECONN|socket|HTTP \d/i.test(String(e.message || ''))) continue; // 链路问题 -> 换端点
|
|
226
|
+
throw e; // 数据层问题 -> 换端点也没用
|
|
227
|
+
}
|
|
228
|
+
const node = (obj.data && obj.data[code]) || {};
|
|
229
|
+
const rows = node.qfqday || node.day || [];
|
|
230
|
+
if (!rows.length) { lastErr = new Error('K线为空'); continue; }
|
|
231
|
+
if (idx !== _klineEp) {
|
|
232
|
+
_klineEp = idx;
|
|
233
|
+
log(` [降级] K线数据源切换为 ${ep.name}${ep.qfq ? '' : '(不复权,除权日附近数据会失真)'}`);
|
|
234
|
+
}
|
|
235
|
+
const norm = rows.map(r => ({ date: r[0], open: +r[1], close: +r[2], high: +r[3], low: +r[4], vol: +r[5] }))
|
|
236
|
+
.filter(r => r.date && !isNaN(r.close))
|
|
237
|
+
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); // 降序:最新在前
|
|
238
|
+
const till = norm.filter(r => r.date <= anchor);
|
|
239
|
+
const today = till.find(r => r.date === anchor) || null;
|
|
240
|
+
const hist = till.filter(r => r.date < anchor && r.vol > 0);
|
|
241
|
+
return { rows: till, today, hist, source: ep.name, qfq: ep.qfq };
|
|
242
|
+
}
|
|
243
|
+
throw lastErr || new Error('全部 K线端点均不可用');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 实时报价:市值 / 52周高 / 换手率等(qt.gtimg.cn,字段以 ~ 分隔)
|
|
247
|
+
// 字段核对(2026-08 实测):idx44=流通市值(亿)、idx45=总市值(亿),二者不可颠倒。
|
|
248
|
+
// 例:中国石油 idx44=17568亿(A股流通)、idx45=19857亿(总股本1830.21亿股×10.85)。
|
|
249
|
+
async function fetchQuote(code) {
|
|
250
|
+
const txt = await httpGet(QT_QUOTE + code, { json: false });
|
|
251
|
+
const inner = (txt.match(new RegExp('v_' + code + '="([^"]*)"')) || [])[1];
|
|
252
|
+
if (!inner) throw new Error('报价解析失败');
|
|
253
|
+
const f = inner.split('~');
|
|
254
|
+
const num = (i) => { const v = parseFloat(f[i]); return isNaN(v) ? null : v; };
|
|
255
|
+
const yi2unit = (i) => { const v = num(i); return v == null ? null : v * 1e8; }; // 腾讯市值单位为“亿”
|
|
256
|
+
const q = {
|
|
257
|
+
code, name: f[1] || code,
|
|
258
|
+
current: num(3), prevClose: num(4), open: num(5),
|
|
259
|
+
high: num(33), low: num(34),
|
|
260
|
+
chg: num(31), chgPct: num(32),
|
|
261
|
+
turnover: num(38), // 换手率 %(仅当日实时口径,盘前为 0)
|
|
262
|
+
circulating_market_cap: yi2unit(44), // 流通市值(元)
|
|
263
|
+
total_market_cap: yi2unit(45), // 总市值(元)
|
|
264
|
+
high_52week: num(47), low_52week: num(48),
|
|
265
|
+
};
|
|
266
|
+
// 流通股本(股)= 流通市值 / 现价;用于按锚定日成交量反推该日换手率
|
|
267
|
+
const px = q.current || q.prevClose;
|
|
268
|
+
q.circulating_shares = (q.circulating_market_cap && px) ? q.circulating_market_cap / px : null;
|
|
269
|
+
return q;
|
|
270
|
+
}
|
|
271
|
+
|
|
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
|
+
}
|
|
289
|
+
|
|
290
|
+
// 分时(最新交易日),返回 [{ time, price, vol, amt }]
|
|
291
|
+
async function fetchMinute(code) {
|
|
292
|
+
const url = `${IFZQ}/minute/query?code=${code}`;
|
|
293
|
+
const obj = await httpGet(url);
|
|
294
|
+
const arr = (obj.data && obj.data[code] && obj.data[code].data && obj.data[code].data.data) || [];
|
|
295
|
+
return arr.map(line => {
|
|
296
|
+
const p = String(line).split(/\s+/);
|
|
297
|
+
return { time: p[0], price: +p[1], vol: +p[2], amt: +p[3] };
|
|
298
|
+
}).filter(r => r.time && !isNaN(r.price));
|
|
299
|
+
}
|
|
300
|
+
|
|
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
|
+
}
|
|
320
|
+
async function mapLimit(items, limit, fn) {
|
|
321
|
+
const res = new Array(items.length);
|
|
322
|
+
let i = 0;
|
|
323
|
+
const workers = [];
|
|
324
|
+
const n = Math.min(limit, items.length);
|
|
325
|
+
for (let w = 0; w < n; w++) {
|
|
326
|
+
workers.push((async () => {
|
|
327
|
+
while (i < items.length) {
|
|
328
|
+
const idx = i++;
|
|
329
|
+
try { res[idx] = await fn(items[idx], idx); }
|
|
330
|
+
catch (e) { res[idx] = { __error: String(e && e.message || e) }; }
|
|
331
|
+
}
|
|
332
|
+
})());
|
|
333
|
+
}
|
|
334
|
+
await Promise.all(workers);
|
|
335
|
+
return res;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------- (以下为腾讯 JSON 接口,无需表格解析;原 westock 表格解析已移除)----------
|
|
339
|
+
|
|
340
|
+
// ===========================================================================
|
|
341
|
+
// 主流程
|
|
342
|
+
// ===========================================================================
|
|
343
|
+
async function main() {
|
|
344
|
+
const args = parseArgs(process.argv.slice(2));
|
|
345
|
+
if (args.help) {
|
|
346
|
+
log('用法: node gen_candidates.js [--date YYYY-MM-DD] [--limit N(0=全部)] [--out file.html] [--quiet]');
|
|
347
|
+
log(' node gen_candidates.js --offline --snapshot data/snapshot-YYYYMMDD.json [--out file.html]');
|
|
348
|
+
log(' node gen_candidates.js --date YYYY-MM-DD --dump data/snapshot-YYYYMMDD.json # 在线抓取后导出快照');
|
|
349
|
+
log(' node gen_candidates.js --no-notify # 实时运行但跳过微信/邮件推送');
|
|
350
|
+
log(' node gen_candidates.js --paths # 打印实际使用的工作目录与各产物落点');
|
|
351
|
+
log(' node gen_candidates.js --init # 补齐工作目录(首次运行时自动执行,此为显式确认)');
|
|
352
|
+
log(' 工作目录(决定快照/报告/配置读写的位置):');
|
|
353
|
+
log(' 默认 = 本仓库根;可用 --cwd <dir> 或环境变量 CANDIDATE_POOL_HOME 重定位。');
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
// --paths:仅打印路径解析结果,便于确认「文件到底写到哪去了」
|
|
357
|
+
if (args.paths) {
|
|
358
|
+
const d = paths.describe();
|
|
359
|
+
log(`工作目录: ${d.home} (来源 ${d.source}${d.relocated ? '' : ',即包/仓库根'})`);
|
|
360
|
+
log(' 工作区(随工作目录移动):');
|
|
361
|
+
for (const [k, v] of Object.entries(d.workspace)) log(` ${k.padEnd(14)} ${v}`);
|
|
362
|
+
log(' 代码资产(永远跟随程序):');
|
|
363
|
+
for (const [k, v] of Object.entries(d.codeAssets)) log(` ${k.padEnd(14)} ${v}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// 首次运行脚手架:空工作目录(npm 包 / npx 场景)补齐目录与观察池,
|
|
367
|
+
// 已有仓库里什么都不缺 → 完全静默。放在 --paths / --print-anchor 之后,
|
|
368
|
+
// 因为那两个是纯诊断入口,不应产生任何写入。
|
|
369
|
+
// --init 仅做初始化然后退出(显式确认环境,不去联网跑一遍)
|
|
370
|
+
// 无参数 静默补齐后**继续正常运行**
|
|
371
|
+
if (process.argv.includes('--init')) { scaffold.autoInit({ log, init: true }); return; }
|
|
372
|
+
scaffold.autoInit({ log });
|
|
373
|
+
// --print-anchor:仅解析并输出目标锚定日(北京时间推算,时区安全),不发起任何网络请求。
|
|
374
|
+
// 供 CI / 本地定时任务判断「今日快照是否已存在」,避免重复生成造成两端数据分歧。
|
|
375
|
+
if (args.printAnchor) {
|
|
376
|
+
const now = dayjs.tz();
|
|
377
|
+
console.log(args.date || lastClosedTradingDay(now));
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (args.offline) { await runOffline(args); return; }
|
|
381
|
+
const now = dayjs.tz(); // 当前北京时间(dayjs 对象;交易日推算与机器时区无关)
|
|
382
|
+
const anchor = args.date || lastClosedTradingDay(now);
|
|
383
|
+
const target = nextTradingDay(anchor);
|
|
384
|
+
let limit = args.limit || 0; // 0 = 不限制,筛查观察池全部标的
|
|
385
|
+
const outPath = args.out || path.join(paths.reportsDir(), `stock_list_${anchor.replace(/-/g, '')}.html`);
|
|
386
|
+
|
|
387
|
+
log(`[1/8] 锚定日 ${anchor}(面向 ${target} 交易日),候选上限 ${limit}`);
|
|
388
|
+
|
|
389
|
+
// ---- 1. 候选观察池(candidates.json,可编辑;替代 westock ranking CompScore)----
|
|
390
|
+
log(`[1/8] 载入候选观察池(candidates.json),上限 ${limit > 0 ? limit : '全部'} 只`);
|
|
391
|
+
const universe = loadUniverse(limit);
|
|
392
|
+
limit = universe.length;
|
|
393
|
+
const minuteForAnchor = (anchor === lastClosedTradingDay(now));
|
|
394
|
+
log(` 已载入候选 ${universe.length} 只: ${universe.map(u => u.name).join('、')}`);
|
|
395
|
+
|
|
396
|
+
// ---- 2. 逐只: kline + quote + minute(腾讯接口)----
|
|
397
|
+
log(`[2/8] 拉取 ${universe.length} 只标的历史行情/报价/分时 ...`);
|
|
398
|
+
const stocks = await mapLimit(universe, CONCURRENCY, async (u) => {
|
|
399
|
+
const s = { ...u };
|
|
400
|
+
let quote = null;
|
|
401
|
+
try { quote = await fetchQuote(u.code); s.quote = quote; }
|
|
402
|
+
catch (e) { s.quote = { error: String(e.message || e) }; }
|
|
403
|
+
try {
|
|
404
|
+
const k = await fetchKline(u.code, anchor);
|
|
405
|
+
// 锚定日换手率:按该日成交量 + 流通股本推算(不用报价 idx38,盘前为 0 且口径是“此刻”)
|
|
406
|
+
if (k.today && quote && quote.circulating_shares) {
|
|
407
|
+
k.today.turn = calcTurnover(k.today.vol, quote.circulating_shares, u.code);
|
|
408
|
+
}
|
|
409
|
+
s.kline = k;
|
|
410
|
+
} catch (e) { s.kline = { error: String(e.message || e) }; }
|
|
411
|
+
if (minuteForAnchor) {
|
|
412
|
+
try {
|
|
413
|
+
s.minute = (await fetchMinute(u.code)).map(b => ({ date: anchor, time: b.time, price: b.price, vol: b.vol, amt: b.amt }));
|
|
414
|
+
} catch (e) { s.minute = { error: String(e.message || e) }; }
|
|
415
|
+
} else {
|
|
416
|
+
s.minute = []; // 分时接口仅返回最新交易日,与历史锚定日口径不符 -> R05 判为数据缺口
|
|
417
|
+
}
|
|
418
|
+
return s;
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ---- 2.5 数据健康度闸门 ----
|
|
422
|
+
// 全量拦截/网络故障时,下游会把每只票都算成"不满足条件",最终输出一份
|
|
423
|
+
// "0 触发"的报告并照常推送——外观与真实的清淡行情完全一致,极易误导。
|
|
424
|
+
// 因此这里必须先体检,不合格就带非零码中止。
|
|
425
|
+
const klineFails = stocks.filter(s => !s.kline || s.kline.error || !s.kline.today);
|
|
426
|
+
const quoteFails = stocks.filter(s => !s.quote || s.quote.error);
|
|
427
|
+
const wafFails = stocks.filter(s => s.kline && /反爬拦截/.test(String(s.kline.error || '')));
|
|
428
|
+
const failRate = klineFails.length / (stocks.length || 1);
|
|
429
|
+
log(` K线失败 ${klineFails.length}/${stocks.length}(其中被拦截 ${wafFails.length})|报价失败 ${quoteFails.length}/${stocks.length}`);
|
|
430
|
+
if (failRate > MAX_FAIL_RATE) {
|
|
431
|
+
const sample = (klineFails[0] && klineFails[0].kline && klineFails[0].kline.error) || '无有效K线';
|
|
432
|
+
log('');
|
|
433
|
+
const pct = (v) => (v * 100).toFixed(v < 0.01 ? 2 : v < 0.1 ? 1 : 0);
|
|
434
|
+
log(`[中止] K线失败率 ${pct(failRate)}% 超过红线 ${pct(MAX_FAIL_RATE)}%,判定数据源不可用。`);
|
|
435
|
+
log(` 首个错误:${String(sample).slice(0, 160)}`);
|
|
436
|
+
if (wafFails.length > klineFails.length * 0.5) {
|
|
437
|
+
log(' 多数失败为反爬拦截:请降低并发(CANDIDATE_CONCURRENCY=3)或避开盘中时段,稍后重试。');
|
|
438
|
+
}
|
|
439
|
+
log(' 本次不生成报告、不覆盖快照、不推送通知。');
|
|
440
|
+
process.exitCode = 1;
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- 3. 计算 R01 ----
|
|
445
|
+
log(`[3/8] 计算 R01 量能验证突破 ...`);
|
|
446
|
+
for (const s of stocks) {
|
|
447
|
+
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;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---- 4. R07 行业涨幅 + 补涨映射(基于观察池内同行业个股涨幅中位数)----
|
|
491
|
+
log(`[4/8] 计算观察池各行业当日涨幅中位数并映射 R07 补涨 ...`);
|
|
492
|
+
const validSectors = computeSectors(stocks);
|
|
493
|
+
const topN = validSectors.length ? validSectors[0].topN : 1;
|
|
494
|
+
const sectorByName = {}; validSectors.forEach(s => sectorByName[s.name] = s);
|
|
495
|
+
for (const s of stocks) {
|
|
496
|
+
const sec = sectorByName[s.sector || '未分类'];
|
|
497
|
+
if (sec) {
|
|
498
|
+
const laggard = sec.inTop10 && (s.r01.chg != null) && (s.r01.chg < 0.5 * sec.pct);
|
|
499
|
+
s.r07 = { sectorName: sec.name, sectorPct: sec.pct, sectorRank: sec.rank, inTop10: sec.inTop10, laggard: !!laggard, total: sec.total, topN: sec.topN };
|
|
500
|
+
} else {
|
|
501
|
+
s.r07 = { sectorName: s.sector || '未分类', sectorPct: null, sectorRank: null, inTop10: false, laggard: false, total: validSectors.length, topN, unmatched: true };
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---- 5. R05 尾盘异动核验 ----
|
|
506
|
+
log(`[5/8] 核验 R05 尾盘异动(依赖分时数据)...`);
|
|
507
|
+
for (const s of stocks) {
|
|
508
|
+
const m = s.minute;
|
|
509
|
+
const r05 = { available: false, lateGain: null, lateVolShare: null, fullDayGain: s.r01.chg, gates: {}, verdict: '', note: '' };
|
|
510
|
+
let dayBars = [];
|
|
511
|
+
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;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---- 6. 市场宽度 + 画像(无全市场公开源,标记为暂不可用)----
|
|
535
|
+
log(`[6/8] 市场宽度/画像:本环境无全市场涨跌分布公开源,标记为暂不可用 ...`);
|
|
536
|
+
let breadth = { error: '市场宽度接口暂不可用(需要全市场涨跌分布,本环境无对应公开数据源;可日后接入)' };
|
|
537
|
+
let breadthIsAnchor = false;
|
|
538
|
+
let overview = { error: '市场画像接口暂不可用(需要全市场成交额/情绪聚合,本环境无对应公开数据源;可日后接入)' };
|
|
539
|
+
|
|
540
|
+
// ---- 7-8. 分类 + 生成 HTML(抽取为 classifyAndBuild,离线模式复用)----
|
|
541
|
+
// 实时模式:写出「当日日期」命名的快照(覆盖式写入,每天一份),并推送通知
|
|
542
|
+
const snapshotPath = args.dump || path.join(paths.dataDir(), `snapshot-${anchor}.json`);
|
|
543
|
+
paths.ensureDir(path.dirname(snapshotPath)); // --dump 指向新目录时也要能写出去
|
|
544
|
+
const snap = {
|
|
545
|
+
anchor, target, limit, topN,
|
|
546
|
+
breadth, breadthIsAnchor, overview,
|
|
547
|
+
stocks: stocks.map(s => { const c = Object.assign({}, s); delete c.kline; delete c.quote; delete c.minute; return c; }),
|
|
548
|
+
sectors: validSectors.map(s => { const c = Object.assign({}, s); delete c.members; return c; }),
|
|
549
|
+
};
|
|
550
|
+
fs.writeFileSync(snapshotPath, JSON.stringify(snap));
|
|
551
|
+
log(`[snapshot] 已写出 ${snapshotPath}(覆盖式,按锚定日留档)`);
|
|
552
|
+
const build = classifyAndBuild({ anchor, target, limit, stocks, validSectors, topN, breadth, breadthIsAnchor, overview, outPath, quiet: args.quiet });
|
|
553
|
+
if (!args.noNotify) {
|
|
554
|
+
try { await sendNotify({ anchor, target, breadth, build }); }
|
|
555
|
+
catch (e) { log('[notify] 推送失败(不影响报告生成): ' + (e && e.message || e)); }
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 实时结果推送(微信 + 163 邮箱),结果写入 notify_config.json / 环境变量
|
|
560
|
+
async function sendNotify({ anchor, target, breadth, build }) {
|
|
561
|
+
let notifyMod;
|
|
562
|
+
try { notifyMod = require('./notify'); }
|
|
563
|
+
catch (e) { log('[notify] 未找到 notify.js,跳过推送'); return; }
|
|
564
|
+
const upPctTxt = (breadth && !breadth.error) ? breadth.upPct : null;
|
|
565
|
+
const regime = upPctTxt == null ? '市场宽度数据缺失'
|
|
566
|
+
: upPctTxt >= 70 ? '强势普涨' : upPctTxt >= 50 ? '偏多震荡' : upPctTxt >= 30 ? '分化偏弱' : '弱势普跌';
|
|
567
|
+
const title = `A股次日候选池 · ${anchor} 收盘后初筛`;
|
|
568
|
+
const content = [
|
|
569
|
+
`**锚定日**:${anchor}(面向 ${target} 交易日)`,
|
|
570
|
+
`**硬触发**:R01 ${build.r01Triggers} 只 / R07 补涨 ${build.r07Triggers} 只 / R05 部分触发 ${build.r05Hard} 只`,
|
|
571
|
+
`**梯队**:重点关注 ${build.high.length} | 次级 ${build.secondary.length} | 条件 ${build.conditional.length} | 排除 ${build.excluded.length}`,
|
|
572
|
+
`**市场环境**:${regime}(上涨占比 ${upPctTxt != null ? upPctTxt + '%' : '—'})`,
|
|
573
|
+
``,
|
|
574
|
+
`报告文件:reports/stock_list_${anchor.replace(/-/g, '')}.html`,
|
|
575
|
+
`(HTML 报告已作为附件发送,或前往 GitHub 仓库按日期查看。)`,
|
|
576
|
+
].join('\n');
|
|
577
|
+
const htmlPath = path.join(paths.reportsDir(), `stock_list_${anchor.replace(/-/g, '')}.html`);
|
|
578
|
+
const res = await notifyMod.notify({ title, content, htmlPath });
|
|
579
|
+
for (const [ch, [ok, info]] of res) {
|
|
580
|
+
log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ===========================================================================
|
|
585
|
+
// HTML 生成
|
|
586
|
+
// ===========================================================================
|
|
587
|
+
function b(zh, en) { return `<span data-zh="${zh}" data-en="${en}">${zh}</span>`; }
|
|
588
|
+
function num(n, d = 2) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(d); }
|
|
589
|
+
function pct(n) { return (n == null || isNaN(n)) ? '—' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; }
|
|
590
|
+
function yi(n) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(1) + ' 亿'; }
|
|
591
|
+
function gateCell(pass) {
|
|
592
|
+
if (pass === true) return '<td class="yes">✓</td>';
|
|
593
|
+
if (pass === false) return '<td class="no">✗</td>';
|
|
594
|
+
return '<td class="na">—</td>';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function buildHTML(m) {
|
|
598
|
+
const { anchor, target, limit, universe, breadth, breadthIsAnchor, overview,
|
|
599
|
+
high, secondary, conditional, excluded, r01Triggers, r07Triggers, r05Hard } = m;
|
|
600
|
+
|
|
601
|
+
// 核心结论
|
|
602
|
+
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 ? ',市值达标' : ''}` : '无';
|
|
604
|
+
const conclusion = [
|
|
605
|
+
`<b>${b('R01 ' + (r01Triggers ? `硬触发 ${r01Triggers} 只` : '零硬触发'), 'R01 ' + (r01Triggers ? `${r01Triggers} hard trigger(s)` : 'zero hard triggers'))}。</b>` +
|
|
606
|
+
(r01Triggers ? '' : `${universe.length} 只标的无一满足全部门槛。最接近者为 ${bestStr}。`),
|
|
607
|
+
`<b>${b('R07 ' + (r07Triggers ? `补涨触发 ${r07Triggers} 只` : '零硬触发'), 'R07 ' + (r07Triggers ? `${r07Triggers} laggard trigger(s)` : 'zero hard triggers'))}。</b>` +
|
|
608
|
+
(r07Triggers ? '' : `${universe.length} 只标的中,位于强势行业(前 ${m.topN} 名)且个股涨幅落后于行业中位数一半的标的为零——强势行业内成分股均已跟涨,与 R07 补涨逻辑方向相反。`),
|
|
609
|
+
`<b>${b('R05 判定', 'R05 status')}:</b>` +
|
|
610
|
+
(r05Hard ? `${b('部分触发 ' + r05Hard + ' 只(尾盘量价达标,但净流入需 L2 不可得)', r05Hard + ' partial trigger(s) — late-session volume/price met, but net inflow needs L2 and is unavailable')}。` :
|
|
611
|
+
`${b('未出现硬触发', 'no hard trigger')}。${b('若分时接口未回溯至锚定日则属数据缺口而非信号缺失', 'where intraday data is unavailable for the anchor date this is a data gap, not an absence of signal')}——本报告未以任何替代指标补齐该信号。`),
|
|
612
|
+
`<b>${b('重点关注' + (high.length ? ` ${high.length} 只` : '为空'), 'High-priority tier' + (high.length ? `: ${high.length}` : ': empty'))}。</b>` +
|
|
613
|
+
`${b('按规则纪律,无全部门槛达标即不设重点关注;次级关注 ' + secondary.length + ' 只、条件观察 ' + conditional.length + ' 只、排除 ' + excluded.length + ' 只。',
|
|
614
|
+
'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
|
+
];
|
|
616
|
+
|
|
617
|
+
// R01 矩阵
|
|
618
|
+
let r01Rows = '';
|
|
619
|
+
for (const s of universe) {
|
|
620
|
+
const r = s.r01;
|
|
621
|
+
const cls = r.ok ? ' class="hit"' : '';
|
|
622
|
+
r01Rows += `<tr${cls}>` +
|
|
623
|
+
`<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
|
|
624
|
+
`<td>${b(s.name, s.name)}</td>` +
|
|
625
|
+
`<td>${num(r.close)}</td>` +
|
|
626
|
+
`<td class="${r.chg >= 0 ? 'up' : 'down'}">${pct(r.chg)}</td>` +
|
|
627
|
+
`<td>${num(r.turn)}%</td>` +
|
|
628
|
+
`<td>${num(r.volRatio)}</td>` +
|
|
629
|
+
`<td>${num(r.prior10High)}</td>` +
|
|
630
|
+
gateCell(r.gates.C1) + gateCell(r.gates.C2) + gateCell(r.gates.C3) + gateCell(r.gates.C4) +
|
|
631
|
+
`<td><b>${r.core != null ? r.core + ' / 4' : '—'}</b></td>` +
|
|
632
|
+
`<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
|
+
`<td>${x.sectorName ? b(x.sectorName, x.sectorName) : '—'}</td>` +
|
|
644
|
+
`<td>${pct(x.sectorPct)}</td>` +
|
|
645
|
+
`<td>${x.sectorRank ? x.sectorRank + '/' + x.total : '—'}</td>` +
|
|
646
|
+
`<td>${x.inTop10 ? '<span class="yes">★强势</span>' : '<span class="no">—</span>'}</td>` +
|
|
647
|
+
`<td>${x.laggard ? '<span class="yes">补涨</span>' : '<span class="no">领涨</span>'}</td>` +
|
|
648
|
+
`</tr>`;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 排除表
|
|
652
|
+
let exRows = '';
|
|
653
|
+
for (const s of excluded) {
|
|
654
|
+
exRows += `<tr>` +
|
|
655
|
+
`<td>${s.code.replace(/^[sh|sz]/, '')}</td>` +
|
|
656
|
+
`<td>${b(s.name, s.name)}</td>` +
|
|
657
|
+
`<td class="wrap">${(s.excludeReasons || ['—']).map(r => b(r, r)).join(';')}</td>` +
|
|
658
|
+
`</tr>`;
|
|
659
|
+
}
|
|
660
|
+
if (!exRows) exRows = `<tr><td colspan="3" class="no">${b('无', 'None')}</td></tr>`;
|
|
661
|
+
|
|
662
|
+
// 次级/条件卡片
|
|
663
|
+
function watchCard(s, label) {
|
|
664
|
+
const r = s.r01;
|
|
665
|
+
const fund = s.fund && !s.fund.error && s.fund.MainNetFlow != null
|
|
666
|
+
? `<div class="kv"><span class="label">${b('主力净流入', 'Main net inflow')}:</span><span class="value ${s.fund.MainNetFlow >= 0 ? 'up' : 'down'}">${yi(+s.fund.MainNetFlow / 1e8)}</span></div>`
|
|
667
|
+
: '';
|
|
668
|
+
const r07 = s.r07 || {};
|
|
669
|
+
return `<div class="card ${label === 'priority' ? 'priority' : ''}">` +
|
|
670
|
+
`<div class="grid-2">` +
|
|
671
|
+
`<div><div class="kv"><span class="label">${b('名称', 'Name')}:</span><span class="value">${b(s.name, s.name)}(${s.code.replace(/^[sh|sz]/, '')})</span></div>` +
|
|
672
|
+
`<div class="kv"><span class="label">${b('R01 达成', 'R01 score')}:</span><span class="value">${r.core}/4${r.capOk ? ',市值达标' : ',市值越线'}</span></div>` +
|
|
673
|
+
`<div class="kv"><span class="label">${b('行业', 'Sector')}:</span><span class="value">${r07.sectorName || '—'}(${pct(r07.sectorPct)},排名 ${r07.sectorRank || '—'}/${r07.total || '—'})</span></div></div>` +
|
|
674
|
+
`<div>${fund}` +
|
|
675
|
+
`<div class="kv"><span class="label">${b('R07 状态', 'R07')}:</span><span class="value">${r07.laggard ? b('板块补涨滞后', 'sector laggard') : b('板块领涨', 'sector leader')}</span></div>` +
|
|
676
|
+
`<div class="kv"><span class="label">${b('R05', 'R05')}:</span><span class="value">${(() => { if (!s.r05) return '—'; if (s.r05.verdict === 'gap') return b('数据缺口', 'data gap'); if (s.r05.verdict === 'partial') return b('尾盘量价达标', 'late-session met'); return b('未触发', 'not triggered'); })()}</span></div></div>` +
|
|
677
|
+
`</div></div>`;
|
|
678
|
+
}
|
|
679
|
+
let secondaryHtml = secondary.length ? secondary.map(s => watchCard(s, 'priority')).join('') : `<div class="card empty"><p class="body-text">${b('无标的达成 R01 核心 3/4 且市值达标。', 'No name met R01 core 3/4 with market cap in range.')}</p></div>`;
|
|
680
|
+
let conditionalHtml = conditional.length ? conditional.map(s => watchCard(s, '')).join('') : `<div class="card empty"><p class="body-text">${b('无标的处于「仅 C3+C4 达标」的条件观察档。', 'No name sits in the conditional tier (C3+C4 only).')}</p></div>`;
|
|
681
|
+
|
|
682
|
+
// 重点关注
|
|
683
|
+
let highHtml = high.length ? high.map(s => watchCard(s, 'priority')).join('') : `<div class="card empty"><p class="body-text">${b('重点关注为空:按规则纪律,仅当 R01 全部门槛(量能/突破/涨幅/换手/市值/非高位区)同时达标才列入。本日无此类标的。', 'High-priority tier is empty: under rule discipline a name is listed only when all R01 gates (volume / breakout / gain / turnover / cap / not-at-52w-high) clear together. None qualified today.')}</p></div>`;
|
|
684
|
+
|
|
685
|
+
// R05 说明
|
|
686
|
+
const r05any = universe.some(s => s.r05 && s.r05.available);
|
|
687
|
+
const r05Gap = universe.some(s => s.r05 && s.r05.verdict === 'gap');
|
|
688
|
+
const r05Note = r05Gap
|
|
689
|
+
? b(`R05 属于数据缺口:分时接口(腾讯分时,仅返回最新交易日)与锚定日 ${anchor} 口径可能不符,因此 14:30–15:00 涨幅与 14:30 后成交量占比两项核心门槛无法验证。本报告未以任何替代指标补齐该信号。`,
|
|
690
|
+
`R05 is a data gap: the intraday endpoint (Tencent minute, returns only the latest session) may not match the anchor date ${anchor}, so the two core gates (gain between 14:30–15:00, and ≥20% of daily volume after 14:30) cannot be verified. No substitute indicator was used to fill this gap.`)
|
|
691
|
+
: (r05any
|
|
692
|
+
? b('R05 已基于可得分时数据核验:尾盘涨幅与尾盘量能占比两项可算;「尾盘主力净流入」需逐笔 L2 数据,分时接口不含,故不可得。仅当尾盘涨幅 ≥2%、尾盘量能占比 ≥20% 且全天涨幅 <7% 时记为部分触发。',
|
|
693
|
+
'R05 was checked against available intraday data: late-session gain and late-volume share are computable; net late-session main inflow requires L2 tick data not present in the minute feed, so it is unavailable. A partial trigger is recorded only when late gain ≥2%, late-volume share ≥20% and full-day gain <7%.')
|
|
694
|
+
: b('R05 未核验。', 'R05 not evaluated.'));
|
|
695
|
+
|
|
696
|
+
// 市场宽度
|
|
697
|
+
let breadthHtml = '';
|
|
698
|
+
if (breadth && !breadth.error) {
|
|
699
|
+
const label = breadthIsAnchor ? b('市场宽度(锚定日收盘)', 'Market breadth (anchor close)') : b('市场宽度(最新交易日快照,非锚定日历史值)', 'Market breadth (latest session snapshot, not the anchor date)');
|
|
700
|
+
breadthHtml = `<p class="body-text">${label}</p><div class="table-wrap"><table><thead><tr>` +
|
|
701
|
+
['上涨', '下跌', '平盘', '涨停', '跌停', '停牌', '上涨占比', '两市成交额'].map(h => `<th>${b(h, h)}</th>`).join('') +
|
|
702
|
+
`</tr></thead><tbody><tr>` +
|
|
703
|
+
`<td class="up">${breadth.up}</td><td class="down">${breadth.down}</td><td>${breadth.flat}</td>` +
|
|
704
|
+
`<td class="up">${breadth.limitUp}</td><td class="down">${breadth.limitDown}</td><td>${breadth.halt}</td>` +
|
|
705
|
+
`<td class="up">${breadth.upPct}%</td><td>${breadth.amountYi != null ? (breadth.amountYi / 1e8).toFixed(0) + ' 亿' : '—'}</td>` +
|
|
706
|
+
`</tr></tbody></table></div>` +
|
|
707
|
+
(breadthIsAnchor ? '' : `<p class="src">${b('注:涨跌分布接口仅提供最新交易日,非锚定日历史值;历史回填时该数据不代表锚定日真实宽度。', 'Note: the breadth endpoint provides only the latest session, not the anchor date; for historical backfills this does not reflect the anchor day’s true breadth.')}</p>`);
|
|
708
|
+
} else {
|
|
709
|
+
breadthHtml = `<p class="body-text">${b('涨跌分布数据暂不可得。', 'Breadth data temporarily unavailable.')}</p>`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 市场画像
|
|
713
|
+
let overviewHtml = '';
|
|
714
|
+
if (overview && !overview.error) {
|
|
715
|
+
const pick = ['大小盘轮动', '行业轮动', '板块宽度', '情绪指标', '个股宽度', '短期趋势方向', '短期趋势强度', '估值水平', '成交量能'];
|
|
716
|
+
const rows = overview.filter(o => pick.includes(o.dim)).map(o => `<tr><td>${b(o.dim, o.dim)}</td><td>${o.score}</td><td>${b(o.state, o.state)}</td></tr>`).join('');
|
|
717
|
+
overviewHtml = `<div class="table-wrap"><table><thead><tr><th>${b('维度', 'Dimension')}</th><th>${b('得分', 'Score')}</th><th>${b('状态', 'State')}</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
718
|
+
} else {
|
|
719
|
+
overviewHtml = `<p class="body-text">${b('市场画像数据暂不可得。', 'Market-profile data temporarily unavailable.')}</p>`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 市场整体判断叙述
|
|
723
|
+
const upPctTxt = (breadth && !breadth.error) ? breadth.upPct : null;
|
|
724
|
+
const regime = upPctTxt == null ? b('市场宽度数据缺失', 'breadth data missing')
|
|
725
|
+
: upPctTxt >= 70 ? b('强势普涨', 'broad strong rally')
|
|
726
|
+
: upPctTxt >= 50 ? b('偏多震荡', 'mildly bullish')
|
|
727
|
+
: upPctTxt >= 30 ? b('分化偏弱', 'divergent / soft')
|
|
728
|
+
: b('弱势普跌', 'broad decline');
|
|
729
|
+
const marketNarrative = b(
|
|
730
|
+
`当前为${regime}环境(上涨占比 ${upPctTxt != null ? upPctTxt + '%' : '—'},涨停 ${breadth ? breadth.limitUp : '—'} 只)。综合评分候选池均来自强势榜单,但在普涨日里 R01 量能突破与 R07 补涨的信号噪声偏高,需结合 R05 尾盘资金确认强度后再做决策。本报告仅作观察评级,不构成任何买卖建议。`,
|
|
731
|
+
`The regime is ${regime} (advance ratio ${upPctTxt != null ? upPctTxt + '%' : '—'}, ${breadth ? breadth.limitUp : '—'} limit-ups). The composite-score universe all comes from strong-rank lists, but on a broad up-day the R01 volume-breakout and R07 laggard signals are noisy; confirm strength with R05 late-session capital before any decision. This report is observation-only and is not investment advice.`
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
// 数据口径
|
|
735
|
+
const provenance = b(
|
|
736
|
+
`候选起点为本目录 <code>candidates.json</code> 配置的可编辑观察池(覆盖主要行业,可自由增删),不再依赖已随 WorkBuddy 安装目录变更而失效的 westock 内置技能。行情数据改为直连腾讯公开接口:日线(前复权)与分时来自 <code>web.ifzq.gtimg.cn</code>,实时报价/总市值/流通市值/52周高/换手率来自 <code>qt.gtimg.cn</code>。全部分析锚定 ${anchor} 收盘,日线截断至 ${anchor},不使用该日之后数据。R07 行业涨幅由观察池内同行业个股当日涨幅中位数推导(申万行业分类口径)。市值对总市值与流通市值同时校验。市场宽度与画像因本环境无全市场公开数据源,暂标记为不可用。`,
|
|
737
|
+
`The starting universe is the editable watchlist in <code>candidates.json</code> (covers major sectors, freely editable), replacing the westock builtin skill that became unavailable after a WorkBuddy install-path change. Market data now comes directly from Tencent's public endpoints: daily bars (forward-adjusted) and intraday from <code>web.ifzq.gtimg.cn</code>; realtime quote / total cap / free-float cap / 52-week high / turnover from <code>qt.gtimg.cn</code>. All analysis is anchored to the ${anchor} close; daily bars are truncated at ${anchor} and no later data is used. R07 sector return is the median same-day gain of the watchlist's constituents in that sector (SW-sector classification). Market cap is checked against both total and free-float. Market breadth and profile are marked unavailable because no market-wide public source is available in this environment.`
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
// 组装
|
|
741
|
+
const css = ` :root {
|
|
742
|
+
--bg: #fffbeb; --card: #fff; --text: #1c1917; --muted: #92400e;
|
|
743
|
+
--border: #fde68a; --accent: #b45309; --red: #dc2626; --green: #16a34a;
|
|
744
|
+
--blue: #2563eb; --amber: #d97706; --gold: #ca8a04; --slate: #64748b;
|
|
745
|
+
}
|
|
746
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
747
|
+
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; }
|
|
748
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
749
|
+
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
|
|
750
|
+
h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; }
|
|
751
|
+
h2 { font-size: 1.15rem; font-weight: 600; margin: 30px 0 12px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
|
|
752
|
+
h3 { font-size: 1.05rem; font-weight: 600; margin-bottom: 10px; }
|
|
753
|
+
.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; }
|
|
756
|
+
.meta-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 0.88rem; margin-bottom: 18px; }
|
|
757
|
+
.meta-bar .label { color: var(--muted); }
|
|
758
|
+
.meta-bar .value { font-weight: 600; }
|
|
759
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; }
|
|
760
|
+
.card.priority { border-left: 4px solid var(--gold); }
|
|
761
|
+
.card.empty { border-left: 4px solid var(--slate); background: #fafaf9; }
|
|
762
|
+
.callout { background: #fff7ed; border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 10px; padding: 16px 20px; margin-bottom: 18px; }
|
|
763
|
+
.callout h3 { color: var(--accent); margin-bottom: 8px; }
|
|
764
|
+
.callout ul { margin: 6px 0 0 18px; font-size: 0.9rem; }
|
|
765
|
+
.callout li { margin-bottom: 5px; }
|
|
766
|
+
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; }
|
|
767
|
+
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 20px; }
|
|
768
|
+
@media (max-width: 640px) { .grid-2, .grid-3 { grid-template-columns: 1fr; } }
|
|
769
|
+
.kv { font-size: 0.9rem; }
|
|
770
|
+
.kv .label { color: var(--muted); }
|
|
771
|
+
.kv .value { font-weight: 600; }
|
|
772
|
+
.up { color: var(--red); }
|
|
773
|
+
.down { color: var(--green); }
|
|
774
|
+
.flat { color: var(--slate); }
|
|
775
|
+
.score { display: inline-block; font-size: 0.95rem; font-weight: 700; padding: 2px 12px; border-radius: 6px; background: #fffbeb; color: var(--gold); }
|
|
776
|
+
.tag { display: inline-block; font-size: 0.78rem; padding: 2px 8px; border-radius: 4px; margin: 2px 4px 2px 0; background: #eff6ff; color: var(--blue); }
|
|
777
|
+
.tag.fail { background: #f1f5f9; color: var(--slate); }
|
|
778
|
+
.tag.pass { background: #fef2f2; color: var(--red); }
|
|
779
|
+
.tag.gap { background: #fffbeb; color: var(--amber); }
|
|
780
|
+
.body-text { font-size: 0.9rem; }
|
|
781
|
+
.risk { margin-top: 12px; padding: 8px 12px; background: #fef2f2; border-radius: 6px; font-size: 0.85rem; color: var(--red); }
|
|
782
|
+
.note { margin-top: 12px; padding: 8px 12px; background: #f8fafc; border-radius: 6px; font-size: 0.85rem; color: var(--slate); }
|
|
783
|
+
.table-wrap { overflow-x: auto; }
|
|
784
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
785
|
+
th, td { padding: 8px 9px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
786
|
+
th { color: var(--muted); font-weight: 600; background: #fefce8; }
|
|
787
|
+
td.wrap { white-space: normal; min-width: 210px; }
|
|
788
|
+
tr.hit { background: #fffdf5; }
|
|
789
|
+
.yes { color: var(--red); font-weight: 700; }
|
|
790
|
+
.no { color: var(--slate); }
|
|
791
|
+
.na { color: var(--amber); }
|
|
792
|
+
.src { font-size: 0.78rem; color: var(--slate); margin-top: 8px; font-style: normal; }
|
|
793
|
+
.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; }`;
|
|
795
|
+
|
|
796
|
+
return `<!DOCTYPE html>
|
|
797
|
+
<html lang="zh-CN">
|
|
798
|
+
<head>
|
|
799
|
+
<meta charset="UTF-8">
|
|
800
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
801
|
+
<title>${b(`A股次日候选池 — ${anchor} 收盘后初筛`, `A-Share Next-Day Candidate Pool — Post-Close Screening, ${anchor}`)}</title>
|
|
802
|
+
<style>${css}</style>
|
|
803
|
+
</head>
|
|
804
|
+
<body>
|
|
805
|
+
<div class="container">
|
|
806
|
+
<div class="topbar">
|
|
807
|
+
<div>
|
|
808
|
+
<h1>${b(`A股次日候选池 — ${anchor} 收盘后初筛`, `A-Share Next-Day Candidate Pool — Post-Close Screening, ${anchor}`)}</h1>
|
|
809
|
+
<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
|
+
</div>
|
|
811
|
+
<button id="langBtn" type="button">EN</button>
|
|
812
|
+
</div>
|
|
813
|
+
|
|
814
|
+
<div class="meta-bar">
|
|
815
|
+
<div><span class="label">${b('锚定日:', 'Anchor date:')}</span><span class="value">${anchor}</span></div>
|
|
816
|
+
<div><span class="label">${b('候选池:', 'Universe:')}</span><span class="value">${limit} ${b('只', 'names')}</span></div>
|
|
817
|
+
<div><span class="label">${b('硬触发:', 'Hard triggers:')}</span><span class="value" style="color:var(--slate)">R01 ${r01Triggers} / R07 ${r07Triggers} / R05 ${r05Hard}</span></div>
|
|
818
|
+
<div><span class="label">${b('市场环境:', 'Market regime:')}</span><span class="value ${upPctTxt != null && upPctTxt >= 50 ? 'up' : 'flat'}">${regime}</span></div>
|
|
819
|
+
</div>
|
|
820
|
+
|
|
821
|
+
<div class="callout">
|
|
822
|
+
<h3>${b('核心结论(先行)', 'Key Conclusions (Up Front)')}</h3>
|
|
823
|
+
<ul>${conclusion.map(c => `<li>${c}</li>`).join('')}</ul>
|
|
824
|
+
</div>
|
|
825
|
+
|
|
826
|
+
<h2>${b('数据口径与偏差声明', 'Data Provenance and Deviations')}</h2>
|
|
827
|
+
<div class="card">
|
|
828
|
+
<p class="body-text">${provenance}</p>
|
|
829
|
+
<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
|
+
</div>
|
|
831
|
+
|
|
832
|
+
<h2>${b('R01 量能验证突破 — 全量判定明细', 'R01 Volume-Confirmed Breakout — Full Evaluation Matrix')}</h2>
|
|
833
|
+
<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>
|
|
836
|
+
<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>
|
|
840
|
+
<th>C1</th><th>C2</th><th>C3</th><th>C4</th>
|
|
841
|
+
<th>${b('达成', 'Score')}</th><th>${b('总市值', 'Mkt cap')}</th>
|
|
842
|
+
</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>
|
|
857
|
+
</table></div>
|
|
858
|
+
</div>
|
|
859
|
+
|
|
860
|
+
<h2>${b('重点关注', 'High-Priority Watch')}</h2>
|
|
861
|
+
${highHtml}
|
|
862
|
+
|
|
863
|
+
<h2>${b('次级关注', 'Secondary Watch')}</h2>
|
|
864
|
+
${secondaryHtml}
|
|
865
|
+
|
|
866
|
+
<h2>${b('条件观察', 'Conditional Watch')}</h2>
|
|
867
|
+
${conditionalHtml}
|
|
868
|
+
|
|
869
|
+
<h2>${b('排除说明', 'Exclusions')}</h2>
|
|
870
|
+
<div class="card">
|
|
871
|
+
<div class="table-wrap"><table>
|
|
872
|
+
<thead><tr><th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('排除原因', 'Reason')}</th></tr></thead>
|
|
873
|
+
<tbody>${exRows}</tbody>
|
|
874
|
+
</table></div>
|
|
875
|
+
</div>
|
|
876
|
+
|
|
877
|
+
<h2>${b('R05 尾盘异动核验', 'R05 Late-Session Capital Surge')}</h2>
|
|
878
|
+
<div class="card">
|
|
879
|
+
<p class="body-text">${r05Note}</p>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
<h2>${b('市场整体判断', 'Overall Market Assessment')}</h2>
|
|
883
|
+
<div class="card">
|
|
884
|
+
${breadthHtml}
|
|
885
|
+
<div style="height:14px"></div>
|
|
886
|
+
${overviewHtml}
|
|
887
|
+
<div class="note">${marketNarrative}</div>
|
|
888
|
+
</div>
|
|
889
|
+
|
|
890
|
+
<div class="disclaimer">
|
|
891
|
+
<b>${b('风险提示与免责声明', 'Risk Disclaimer')}:</b>${b('本报告由程序基于公开市场数据自动生成,仅用于短线交易候选池的量化初筛与观察评级,不构成任何投资建议或买卖邀约。所有判定均基于历史/收盘数据,存在前视偏差与数据缺口(如 R05 分时不可得)。市场有风险,决策需独立判断并自担风险。', 'This report is generated automatically from public market data for quantitative pre-screening and observation-only rating of short-term trading candidates. It is not investment advice or a solicitation to trade. All judgments rely on historical/close data and may contain look-ahead bias and data gaps (e.g. R05 intraday unavailable). Markets carry risk; decisions must be made independently and at your own risk.')}
|
|
892
|
+
</div>
|
|
893
|
+
|
|
894
|
+
<div class="footer">${b('生成于 ' + nowStr(), 'Generated ' + nowStr())} · ${b('A股短线交易 · 次日候选池自动初筛', 'A-Share Short-Term Trading · Automated Next-Day Candidate Screening')}</div>
|
|
895
|
+
</div>
|
|
896
|
+
|
|
897
|
+
<script>
|
|
898
|
+
(function () {
|
|
899
|
+
var btn = document.getElementById('langBtn');
|
|
900
|
+
var lang = 'zh';
|
|
901
|
+
var nodes = document.querySelectorAll('[data-zh]');
|
|
902
|
+
nodes.forEach(function (el) {
|
|
903
|
+
if (!el.getAttribute('data-zh')) { el.setAttribute('data-zh', el.innerHTML); }
|
|
904
|
+
});
|
|
905
|
+
function apply(target) {
|
|
906
|
+
nodes.forEach(function (el) {
|
|
907
|
+
var val = el.getAttribute('data-' + target);
|
|
908
|
+
if (val !== null && val !== '') { el.innerHTML = val; }
|
|
909
|
+
});
|
|
910
|
+
document.documentElement.lang = (target === 'zh') ? 'zh-CN' : 'en';
|
|
911
|
+
document.title = (target === 'zh')
|
|
912
|
+
? 'A股次日候选池 — ${anchor} 收盘后初筛'
|
|
913
|
+
: 'A-Share Next-Day Candidate Pool — Post-Close Screening, ${anchor}';
|
|
914
|
+
btn.textContent = (target === 'zh') ? 'EN' : '中文';
|
|
915
|
+
}
|
|
916
|
+
btn.addEventListener('click', function () { lang = (lang === 'zh') ? 'en' : 'zh'; apply(lang); });
|
|
917
|
+
apply(lang);
|
|
918
|
+
})();
|
|
919
|
+
</script>
|
|
920
|
+
</body>
|
|
921
|
+
</html>`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 分类 + 生成 HTML(在线与离线共用)
|
|
925
|
+
function classifyAndBuild(m) {
|
|
926
|
+
const { anchor, target, limit, stocks, validSectors, topN, breadth, breadthIsAnchor, overview, outPath, quiet } = m;
|
|
927
|
+
// ---- 7. 分类 ----
|
|
928
|
+
log(`[7/8] 分类: 重点关注 / 次级关注 / 排除 ...`);
|
|
929
|
+
const high = [], secondary = [], conditional = [], excluded = [];
|
|
930
|
+
for (const s of stocks) {
|
|
931
|
+
const r = s.r01;
|
|
932
|
+
if (r.ok) high.push(s);
|
|
933
|
+
else if (r.capOk && r.highZoneOk && r.core >= 2) {
|
|
934
|
+
if (r.core >= 3) secondary.push(s); else conditional.push(s);
|
|
935
|
+
} else {
|
|
936
|
+
const reasons = [];
|
|
937
|
+
if (!r.capOk && r.mktCapYi != null) reasons.push(`市值 ${r.mktCapYi.toFixed(1)} 亿超出 20–500 亿区间`);
|
|
938
|
+
if (r.capOk === false && r.mktCapYi == null) reasons.push('市值数据缺失');
|
|
939
|
+
if (r.highZoneOk === false) reasons.push('处于 52 周高位区');
|
|
940
|
+
if (r.core < 2) reasons.push(`量价突破仅达成 ${r.core}/4 项`);
|
|
941
|
+
if (r.error) reasons.push('行情数据缺失');
|
|
942
|
+
s.excludeReasons = reasons;
|
|
943
|
+
excluded.push(s);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
high.sort((a, b) => b.r01.core - a.r01.core);
|
|
947
|
+
secondary.sort((a, b) => b.r01.core - a.r01.core);
|
|
948
|
+
conditional.sort((a, b) => b.r01.core - a.r01.core);
|
|
949
|
+
excluded.sort((a, b) => b.r01.core - a.r01.core);
|
|
950
|
+
|
|
951
|
+
const r01Triggers = high.length;
|
|
952
|
+
const r07Triggers = stocks.filter(s => s.r07 && s.r07.laggard).length;
|
|
953
|
+
const r05Hard = stocks.filter(s => s.r05 && s.r05.verdict === 'partial').length;
|
|
954
|
+
|
|
955
|
+
// ---- 8. 生成 HTML ----
|
|
956
|
+
log(`[8/8] 生成双语 HTML -> ${outPath}`);
|
|
957
|
+
const html = buildHTML({
|
|
958
|
+
anchor, target, limit, universe: stocks, sectors: validSectors, topN,
|
|
959
|
+
breadth, breadthIsAnchor, overview,
|
|
960
|
+
high, secondary, conditional, excluded,
|
|
961
|
+
r01Triggers, r07Triggers, r05Hard,
|
|
962
|
+
});
|
|
963
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
964
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
965
|
+
log(`完成。硬触发 R01=${r01Triggers} 只 / R07 补涨=${r07Triggers} 只 / R05 部分触发=${r05Hard} 只`);
|
|
966
|
+
log(`输出: ${outPath}`);
|
|
967
|
+
if (!quiet) process.stdout.write(outPath + '\n');
|
|
968
|
+
return { high, secondary, conditional, excluded, r01Triggers, r07Triggers, r05Hard, anchor, target, outPath };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// 离线模式:从快照载入,跳过全部网络请求
|
|
972
|
+
async function runOffline(args) {
|
|
973
|
+
let snapPath = args.snapshot;
|
|
974
|
+
if (!snapPath) {
|
|
975
|
+
// 未指定 --snapshot 时,自动取 HOME/data 下日期最新的一份快照(与 daily-screen.yml 离线兜底同口径)
|
|
976
|
+
const dir = paths.dataDir();
|
|
977
|
+
if (fs.existsSync(dir)) {
|
|
978
|
+
const files = fs.readdirSync(dir)
|
|
979
|
+
.filter(f => /^snapshot-\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
|
|
980
|
+
if (files.length) snapPath = path.join(dir, files[files.length - 1]);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (!snapPath) { log('离线模式需要 --snapshot <快照文件>,或 data/ 下存在 snapshot-YYYY-MM-DD.json'); process.exit(1); }
|
|
984
|
+
const snap = JSON.parse(fs.readFileSync(snapPath, 'utf8'));
|
|
985
|
+
const stocks = snap.stocks;
|
|
986
|
+
const validSectors = snap.sectors || [];
|
|
987
|
+
const { anchor, target, limit } = snap;
|
|
988
|
+
const topN = snap.topN, breadth = snap.breadth, breadthIsAnchor = snap.breadthIsAnchor, overview = snap.overview;
|
|
989
|
+
const outPath = args.out || path.join(paths.reportsDir(), `stock_list_${anchor.replace(/-/g, '')}.html`);
|
|
990
|
+
log(`[offline] 从快照 ${snapPath} 载入 ${stocks.length} 只,锚定 ${anchor}(面向 ${target})`);
|
|
991
|
+
classifyAndBuild({ anchor, target, limit, stocks, validSectors, topN, breadth, breadthIsAnchor, overview, outPath, quiet: args.quiet });
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
main().catch(e => { log('FATAL: ' + (e && e.stack || e)); process.exit(1); });
|