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.
@@ -0,0 +1,373 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/tail_screener.js —— 尾盘选股入口(FR-01 ~ FR-11 的编排层)
4
+ * ---------------------------------------------------------------------------
5
+ * 流程:
6
+ * 1. 时段判定(sessionState):非交易日 / 未到观察点 / 超宽限期 → 跳过或告警退出
7
+ * 2. 全市场快照(fullMarketSnapshot)→ 行情时间戳众数推交易日 → 复核时段
8
+ * 3. 初筛(preFilter)→ 幸存者抓分时(fetchMinutes,限速 + 熔断)
9
+ * 4. buildCandidates:尾盘段校验 → 六项打分 → 行业分组
10
+ * 5. 幂等落库(store.saveRun)→ 双语 HTML 报告 → notify(微信 + 邮箱)
11
+ *
12
+ * 用法:
13
+ * node eod/tail_screener.js # 正常运行(受时段限制)
14
+ * node eod/tail_screener.js --intraday # 盘中模式:14:30 前也可跑,段=最近 20 分钟
15
+ * node eod/tail_screener.js --intraday --stamp # 盘中模式并在文件名里保留时点
16
+ * node eod/tail_screener.js --stamp # (观察 / 盘中通用)文件名带时点(+ 窗口)
17
+ * # 观察与盘中默认**不带时点**(一天一个文件,
18
+ * # 多次运行靠 runs[] 留痕,时点另存 doc.cutAt);
19
+ * # 要对比不同窗口/不同时刻的完整 records 时才加 --stamp。
20
+ * node eod/tail_screener.js --intraday --seg-minutes 30
21
+ * # 盘中模式的段长改用 30 分钟(默认 20)
22
+ * # 注意:段长变了,尾盘段门槛(tailSegMinPct=0.5)
23
+ * # 与满分线(2.0%)是按 20 分钟标定的,跨窗口
24
+ * # 比较前先看 tail.config.js 的注释。
25
+ * node eod/tail_screener.js --now "2026-09-18 14:55" # 指定时刻(测试 / 补跑)
26
+ * node eod/tail_screener.js --force # 忽略时段,按 14:50 口径取数
27
+ * node eod/tail_screener.js --no-notify # 不推送(测试用)
28
+ * node eod/tail_screener.js --replay data/eod-2026-09-18.json
29
+ * # 离线回放:从存档重建报告,不抓行情
30
+ * node eod/tail_screener.js --resync data/eod-2026-09-21.json
31
+ * # 把存档**重新同步到 D1**(不抓行情、不改本地
32
+ * # 归档、不推送)。落库链路修好后用它补历史;
33
+ * # 可传多份;会先清掉 mode 非 formal/observe
34
+ * # 的遗留行,再按 d1Mode() 翻译后 UPSERT。
35
+ * node eod/tail_screener.js --json # stdout 输出机器可读摘要
36
+ * node eod/tail_screener.js --paths # 打印工作目录与归档/报告落点,不抓行情
37
+ * node eod/tail_screener.js --init # 补齐工作目录(首次运行会自动执行)
38
+ *
39
+ * 时段守卫(默认保留,别去掉):
40
+ * 不加参数时,14:30 之前一律「未到尾盘观察时点」→ 跳过且不产出任何文件。
41
+ * 这是为定时任务设计的护栏(避免上午空跑推送 0 候选)。要盘中随手看一眼,
42
+ * 用 --intraday:它**自动**关闭 D1 同步与推送,并另存 eod-<日>-intraday.json,
43
+ * 绝不覆盖当天的正式口径文件(默认一天一个文件,多次运行记在 runs[];
44
+ * 要完整对比某一时刻的 records 时再加 --stamp)。
45
+ * ---------------------------------------------------------------------------
46
+ */
47
+
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+
51
+ const cfg = require('./tail.config');
52
+ const { sessionState, estimateFullDayVolRatio, nowBjt } = require('./lib/trading');
53
+ const market = require('./lib/market');
54
+ const { buildCandidates } = require('./lib/screen');
55
+ const { saveRun, loadDay, daySuffix } = require('./lib/store');
56
+ const { syncTailRun, cleanupLegacyModes, d1Mode, targetLabel } = require('./lib/store_d1');
57
+ const { buildHTML } = require('./lib/report');
58
+ const { notify } = require('../notify');
59
+ const paths = require('../paths');
60
+ // 首次运行脚手架:空工作目录时补齐目录(尾盘不依赖 candidates.json,但归档/报告目录要存在)
61
+ const scaffold = require('../scaffold');
62
+
63
+ // ---------- CLI ----------
64
+ const argv = process.argv.slice(2);
65
+ function argOf(flag) {
66
+ const i = argv.indexOf(flag);
67
+ return i >= 0 ? argv[i + 1] : null;
68
+ }
69
+ function argsOf(flag) {
70
+ const out = [];
71
+ argv.forEach((v, i) => { if (v === flag && argv[i + 1]) out.push(argv[i + 1]); });
72
+ return out;
73
+ }
74
+ const OPT = {
75
+ now: argOf('--now'),
76
+ force: argv.includes('--force'),
77
+ wait: argv.includes('--wait'),
78
+ intraday: argv.includes('--intraday'),
79
+ segMinutes: Number(argOf('--seg-minutes')) || null,
80
+ stamp: argv.includes('--stamp'),
81
+ noNotify: argv.includes('--no-notify'),
82
+ json: argv.includes('--json'),
83
+ noD1: argv.includes('--no-d1'),
84
+ replay: argOf('--replay'),
85
+ resync: argsOf('--resync'),
86
+ paths: argv.includes('--paths'),
87
+ };
88
+
89
+ /**
90
+ * 盘中模式(--intraday)是**只读自查**用途:口径本身是滚动窗口近似,
91
+ * 不是当日的正式尾盘结果。故强制关掉「外发」两条链路,避免把近似值
92
+ * 当成正式数据写进 D1(网页 /api/tail/* 会读它)或推送到微信/邮箱。
93
+ * 想改这个行为前先想清楚:D1 的 tail_run/tail_pick 只有 formal/observe 两种口径,
94
+ * 盘中数据一旦写进去,复盘页会把它当成正式记录参与对比。
95
+ */
96
+ if (OPT.intraday) {
97
+ OPT.noD1 = true;
98
+ OPT.noNotify = true;
99
+ }
100
+
101
+ /** 运行环境标识:GitHub Actions runner 里 GITHUB_ACTIONS=true */
102
+ const RUNNER = process.env.GITHUB_ACTIONS ? 'github' : 'local';
103
+
104
+ function log(msg) { if (!OPT.json) console.log(msg); }
105
+
106
+ function fail(msg, err) {
107
+ log(`[失败] ${msg}` + (err ? `\n${err && err.stack || err}` : ''));
108
+ if (cfg.notify.enabled && cfg.notify.alertOnFailure && !OPT.noNotify) {
109
+ notify({ title: `尾盘选股失败 · ${nowBjt().format('MM-DD HH:mm')}`, content: `${msg}\n\n${err && err.message || err || ''}` })
110
+ .then((res) => { for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败'}`); })
111
+ .catch(() => {});
112
+ }
113
+ process.exitCode = 1;
114
+ }
115
+
116
+ // ---------- 离线回放:从存档 JSON 重建报告 ----------
117
+ async function runReplay(file) {
118
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
119
+ if (!doc.records || !doc.tradeDate) throw new Error('存档格式不对:缺少 records/tradeDate');
120
+ // 存档只存了压缩记录,重建 result 的最小形状即可满足报告渲染
121
+ const candidates = Object.values(doc.records).map((r) => ({
122
+ ...r,
123
+ tail: r.tail || null,
124
+ score: r.score || {},
125
+ }));
126
+ const groupsMap = new Map();
127
+ for (const c of candidates) {
128
+ const k = c.sector || '未分类';
129
+ if (!groupsMap.has(k)) groupsMap.set(k, []);
130
+ groupsMap.get(k).push(c);
131
+ }
132
+ const groups = [...groupsMap.entries()].map(([sector, list]) => {
133
+ list.sort((a, b) => b.total - a.total);
134
+ list.forEach((c, i) => { c.groupRank = i + 1; c.bestInGroup = i === 0; c.groupSize = list.length; });
135
+ return { sector, size: list.length, shown: list.slice(0, cfg.group.maxPerGroup), best: list[0] };
136
+ }).sort((a, b) => b.best.total - a.best.total);
137
+ candidates.sort((a, b) => b.total - a.total);
138
+
139
+ const result = {
140
+ candidates, groups,
141
+ top: candidates.slice(0, cfg.group.topN),
142
+ stats: doc.stats || {},
143
+ dropped: [],
144
+ };
145
+ return emitResult({
146
+ tradeDate: doc.tradeDate, mode: doc.mode || 'cut', cutHHMM: doc.cutAt || '1450',
147
+ segFrom: cfg.session.observeFrom.replace(':', ''), result,
148
+ meta: { slots: 0, batches: 0, failedBatches: 0, valid: candidates.length, marketDate: doc.tradeDate, elapsedMs: 0, outlierDates: [] },
149
+ skipStore: true,
150
+ });
151
+ }
152
+
153
+ // ---------- 补发:把本地存档重新同步到 D1 ----------
154
+ /**
155
+ * 落库一侧修好之后,用存档把历史数据补进 D1(不抓行情、不写本地 JSON、不推送)。
156
+ * 时序上 D1 只是网页的数据源,本地 JSON 始终是权威归档,所以「重发」是安全的:
157
+ * 用同一份存档再推一次,结果应当与当初成功时一致(UPSERT 幂等)。
158
+ *
159
+ * 补发前会先清掉 mode 不是 formal/observe 的遗留行 —— 2026-09-21 的 14:50 运行曾被
160
+ * 以本地叫法 `cut` 写进去,不清掉的话同一天会出现 `cut` + `formal` 两条同义记录。
161
+ */
162
+ async function runResync(files) {
163
+ log(`[补发] 目标 ${files.length} 份存档`);
164
+ const clean = await cleanupLegacyModes();
165
+ if (clean.skipped) log(`[补发] 清理遗留口径:跳过(${clean.reason})`);
166
+ else {
167
+ for (const r of clean.results) {
168
+ if (r.error) log(`[补发] 清理遗留口径 ${targetLabel(r)}:失败 ${r.error}`);
169
+ else log(`[补发] 清理遗留口径 ${targetLabel(r)}:tail_run 删 ${r.deleted.tail_run} / tail_pick 删 ${r.deleted.tail_pick}`);
170
+ }
171
+ }
172
+
173
+ const report = [];
174
+ for (const f of files) {
175
+ const base = path.basename(f);
176
+ try {
177
+ const doc = JSON.parse(fs.readFileSync(f, 'utf8'));
178
+ if (!doc.records || !doc.tradeDate || !doc.mode) {
179
+ throw new Error('存档格式不对:缺少 records / tradeDate / mode');
180
+ }
181
+ const res = await syncTailRun(doc);
182
+ if (res.skipped) log(`[补发] ${base} → 跳过:${res.reason}`);
183
+ else if (res.error) log(`[补发] ${base} → 失败:${res.error}`);
184
+ else {
185
+ const dst = res.results.map((r) => `${targetLabel(r)}${r.ok ? '' : `(失败 ${r.error})`}`).join(' + ');
186
+ log(`[补发] ${base} → ${d1Mode(doc.mode)}(${doc.tradeDate}):`
187
+ + `${res.picks} 条候选 + 1 行运行记录 → ${dst}`);
188
+ }
189
+ report.push({ file: base, tradeDate: doc.tradeDate, mode: d1Mode(doc.mode), ...res });
190
+ } catch (e) {
191
+ log(`[补发] ${base} → 失败:${e.message || e}`);
192
+ report.push({ file: base, error: e.message || String(e) });
193
+ }
194
+ }
195
+ return { resync: report };
196
+ }
197
+
198
+ // ---------- 主流程 ----------
199
+ async function run() {
200
+ const t0 = Date.now();
201
+
202
+ // --paths:仅打印路径解析结果,便于确认「归档与报告到底写到哪去了」,不抓行情
203
+ if (OPT.paths) {
204
+ const d = paths.describe();
205
+ log(`工作目录: ${d.home} (来源 ${d.source}${d.relocated ? '' : ',即包/仓库根'})`);
206
+ log(' 工作区(随工作目录移动):');
207
+ for (const [k, v] of Object.entries(d.workspace)) log(` ${k.padEnd(14)} ${v}`);
208
+ log(' 代码资产(永远跟随程序):');
209
+ for (const [k, v] of Object.entries(d.codeAssets)) log(` ${k.padEnd(14)} ${v}`);
210
+ return { paths: d };
211
+ }
212
+
213
+ // 首次运行脚手架(放在 --paths 之后:纯诊断入口不应产生写入)
214
+ // --init 仅做初始化然后退出
215
+ // 无参数 静默补齐后**继续正常运行**
216
+ if (argv.includes('--init')) { scaffold.autoInit({ log, init: true }); return { init: true }; }
217
+ scaffold.autoInit({ log });
218
+
219
+ // 离线回放不依赖任何行情与时段( Sundays 也要能重建报告)
220
+ if (OPT.replay) return runReplay(OPT.replay);
221
+
222
+ // 补发到 D1 同样不依赖行情与时段(可在任何时刻手动补历史)
223
+ if (OPT.resync.length) return runResync(OPT.resync);
224
+
225
+ // --wait:GitHub Actions 的 cron 有 1–5 分钟级延迟,故由程序内等到 14:50 再跑,
226
+ // 保证「分时尾盘段完整 + 口径固定」。(计划任务本地触发时不需要它,但加了也无害)
227
+ if (OPT.wait && !OPT.force) {
228
+ const target = nowBjt().hour(+cfg.session.cutAt.slice(0, 2)).minute(+cfg.session.cutAt.slice(3, 5)).second(10);
229
+ const diffMs = target.diff(nowBjt());
230
+ if (diffMs > 0) {
231
+ log(`[等待] 距 ${cfg.session.cutAt} 还有 ${(diffMs / 60000).toFixed(1)} 分钟,程序内等待…`);
232
+ await new Promise((r) => setTimeout(r, diffMs));
233
+ }
234
+ }
235
+
236
+ const now = OPT.now ? nowBjt().year(OPT.now.slice(0, 4)).month(+OPT.now.slice(5, 7) - 1)
237
+ .date(+OPT.now.slice(8, 10)).hour(+OPT.now.slice(11, 13)).minute(+OPT.now.slice(14, 16)).second(0)
238
+ : nowBjt();
239
+
240
+ // 第一遍:本地时钟粗筛(还没行情,marketDate 未知)
241
+ let ss = sessionState({ cfg, now, force: OPT.force, intraday: OPT.intraday, segMinutes: OPT.segMinutes });
242
+ log(`[时段] ${ss.reason}`);
243
+ if (!ss.ok) {
244
+ // 非交易日静默跳过(不告警,这是正常情况;Q16 只要求「失败」告警)
245
+ log('[结束] 跳过本次运行');
246
+ return { skipped: ss.reason };
247
+ }
248
+
249
+ // 1. 全市场快照
250
+ log('[1/4] 抓取全市场快照…');
251
+ const snap = await market.fullMarketSnapshot(cfg, log);
252
+ if (snap.meta.valid < cfg.market.minValidQuotes) {
253
+ throw new Error(`有效报价 ${snap.meta.valid} 低于红线 ${cfg.market.minValidQuotes},数据源疑似不可用`);
254
+ }
255
+ log(` 快照 ${snap.meta.valid} 只(交易日 ${snap.meta.marketDate},${(snap.meta.elapsedMs / 1000).toFixed(1)}s)`);
256
+
257
+ // 第二遍:用行情日期终筛(休市日行情时间戳不推进 → marketDate != today)
258
+ ss = sessionState({ cfg, now, marketDate: snap.meta.marketDate, force: OPT.force, intraday: OPT.intraday, segMinutes: OPT.segMinutes });
259
+ log(`[时段] ${ss.reason}`);
260
+ if (!ss.ok) { log('[结束] 跳过本次运行'); return { skipped: ss.reason }; }
261
+
262
+ // 2. 初筛
263
+ // 尾盘段起点一律取 ss.segFrom:正式/观察 = 固定 1430,盘中 = 滚动窗口起点。
264
+ // 不要再回退到 cfg.session.observeFrom —— 那样盘中模式会去找不存在的 14:30 K 线 → 0 候选。
265
+ const segFrom = ss.segFrom || cfg.session.observeFrom.replace(':', '');
266
+ const cutHHMM = ss.cutTime.replace(':', '');
267
+ log('[2/4] 初筛过滤…');
268
+ const { preFilter } = require('./lib/screen');
269
+ const { passed, byReason } = preFilter(snap.quotes, cfg, map(), ss.tradeDate);
270
+ log(` 初筛通过 ${passed.length} 只`);
271
+
272
+ // 3. 分时抓取(只对幸存者)
273
+ log('[3/4] 抓取分时(尾盘段校验用)…');
274
+ const minutes = await market.fetchMinutes(passed.map((q) => q.code), cfg, log);
275
+ log(` 分时完成,失败 ${[...minutes.values()].filter((m) => m.error).length} 只`);
276
+
277
+ // 4. 打分 + 分组
278
+ log('[4/4] 打分与分组…');
279
+ const result = buildCandidates({
280
+ quotes: snap.quotes, minutes, cfg, map: map(), tradeDate: ss.tradeDate,
281
+ cutHHMM, segFrom,
282
+ makeTail: (rows, code, cut, seg) => market.tailMetrics(rows, code, cut, seg),
283
+ });
284
+ // 收盘预估量比(参考值)
285
+ for (const c of result.candidates) c.volRatioEst = estimateFullDayVolRatio(c.volRatio, ss.elapsed);
286
+
287
+ return emitResult({
288
+ tradeDate: ss.tradeDate, mode: ss.mode, cutHHMM, segFrom,
289
+ segMinutes: ss.segMinutes ?? null, result, meta: snap.meta, _t0: t0,
290
+ });
291
+ }
292
+
293
+ /** 行业映射缓存(懒加载,一次进程只用一份;sectorOf 期望完整文档结构 {stocks:{...}}) */
294
+ let _map = null;
295
+ function map() {
296
+ if (_map) return _map;
297
+ const { loadIndustryMap } = require('./lib/sector');
298
+ _map = loadIndustryMap();
299
+ if (_map.count < 3000) log(`[警告] 行业映射仅覆盖 ${_map.count} 只,分组与板块强度会失真`);
300
+ return _map;
301
+ }
302
+
303
+ /** 落库 + 报告 + 通知(live 与 replay 共用) */
304
+ async function emitResult(p) {
305
+ const { tradeDate, mode, cutHHMM, segFrom, segMinutes, result, meta } = p;
306
+
307
+ // 落库
308
+ let saved = null;
309
+ if (!p.skipStore) {
310
+ saved = saveRun({
311
+ cfg, tradeDate, mode, cutHHMM, result, runner: RUNNER,
312
+ segFrom, segMinutes, stamp: OPT.stamp,
313
+ });
314
+ log(`[落库] ${path.basename(saved.file)}(${saved.recordCount} 条 / 第 ${saved.runCount} 次运行)`);
315
+
316
+ // 同步到数据源(网页 /api/tail/* 读 D1;本地 db/local.db 供离线复盘)。
317
+ // 目标见 store_d1.js::targetArgv():**本机跑默认两边都写,GitHub Actions 只写远程**;
318
+ // `--local` / `--both` 可显式覆盖。--no-d1(盘中模式强制)则完全不写库。
319
+ if (saved.doc && !OPT.noD1) {
320
+ const d1res = await syncTailRun(saved.doc);
321
+ if (d1res.skipped) log(`[库] 跳过:${d1res.reason}`);
322
+ else if (d1res.error) log(`[库] 同步失败(不影响本地归档):${d1res.error}`);
323
+ else {
324
+ for (const r of d1res.results) {
325
+ log(`[库] ${targetLabel(r)}:已同步 ${d1res.picks} 条候选 + 1 行运行记录`);
326
+ }
327
+ for (const n of d1res.notes || []) log(`[库] ${n}`);
328
+ }
329
+ }
330
+ }
331
+
332
+ // 报告
333
+ const reportDir = path.join(paths.eodDir(), cfg.store.reportDir);
334
+ fs.mkdirSync(reportDir, { recursive: true });
335
+ const suffix = daySuffix(mode, cutHHMM, { stamp: OPT.stamp, segMinutes });
336
+ const reportFile = path.join(reportDir, `eod-${tradeDate}${suffix}.html`);
337
+ fs.writeFileSync(reportFile, buildHTML({
338
+ cfg, tradeDate, mode, cutHHMM, segFrom, result, meta, runner: RUNNER,
339
+ }), 'utf8');
340
+ log(`[报告] ${reportFile}`);
341
+
342
+ // 通知
343
+ if (cfg.notify.enabled && !OPT.noNotify) {
344
+ const top = result.top.slice(0, cfg.notify.topInMessage);
345
+ const lines = top.map((c, i) =>
346
+ `${i + 1}. ${c.name}(${c.code.replace(/^[a-z]+/, '')})${c.sector} · 涨${c.chgPct?.toFixed(2)}% · 尾盘段${c.tail?.segPct?.toFixed(2)}% · 量比${c.volRatio?.toFixed(1)} · 总分${c.total.toFixed(1)}`);
347
+ const content = [
348
+ `交易日 ${tradeDate} · ${mode === 'observe' ? '观察模式' : '固定口径'} ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)}`,
349
+ `候选 ${result.stats.candidateCount} 只 / ${result.stats.groupCount} 个行业(快照 ${result.stats.snapshotCount} → 初筛 ${result.stats.prePassCount})`,
350
+ '',
351
+ ...lines,
352
+ '',
353
+ `完整报告见附件(邮件)/ 本地:eod/reports/eod-${tradeDate}${suffix}.html`,
354
+ ].join('\n');
355
+ const res = await notify({ title: `尾盘选股 ${tradeDate}(${result.stats.candidateCount} 只候选)`, content, htmlPath: reportFile });
356
+ for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
357
+ }
358
+
359
+ const summary = {
360
+ tradeDate, mode, cutHHMM,
361
+ snapshot: result.stats.snapshotCount,
362
+ prePass: result.stats.prePassCount,
363
+ candidates: result.stats.candidateCount,
364
+ groups: result.stats.groupCount,
365
+ top: result.top.slice(0, 10).map((c) => ({ code: c.code, name: c.name, total: +c.total.toFixed(1) })),
366
+ reportFile, savedFile: saved ? saved.file : p.skipStore ? p.tradeDate : null,
367
+ elapsedMs: Date.now() - (p._t0 || Date.now()),
368
+ };
369
+ if (OPT.json) console.log(JSON.stringify(summary, null, 2));
370
+ return summary;
371
+ }
372
+
373
+ run().catch((e) => fail('尾盘选股流程异常', e));