candidate-pool 2.1.1 → 3.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,360 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/preopen_screener.js —— 盘前选股入口(Pre-Open Screener 编排层)
4
+ * ---------------------------------------------------------------------------
5
+ * 流程:
6
+ * 1. 时段守卫(sessionState):非交易日 / 未到 9:20 / 已过 9:35 → 跳过
7
+ * 2. 抓竞价快照:观察池(fetchBatch) 或 全市场(fullMarketSnapshot)
8
+ * 3. 基线:前一日 K 线算 昨涨 + 趋势(观察池实时算;全市场用隔夜缓存)
9
+ * 4. 应用 P1/P2/P3 → 排除项 → 综合打分 → 分组
10
+ * 5. 落库(JSON) → HTML 报告 → 通知 → git 推送溯源
11
+ *
12
+ * 用法:
13
+ * node eod/preopen_screener.js # 观察池,正常(受时段限制)
14
+ * node eod/preopen_screener.js --universe # 全市场盲扫(需 baseline 缓存)
15
+ * node eod/preopen_screener.js --scope universe # 同上
16
+ * node eod/preopen_screener.js --baseline # 仅预计算基线缓存(供全市场次日用)
17
+ * node eod/preopen_screener.js --force # 忽略时段,按当前快照取数
18
+ * node eod/preopen_screener.js --now "2026-10-22 09:25" # 指定时刻(测试/补跑)
19
+ * node eod/preopen_screener.js --no-notify # 不推送通知
20
+ * node eod/preopen_screener.js --no-push # 不 git 推送(仅落本地)
21
+ * node eod/preopen_screener.js --json # stdout 机器摘要
22
+ * node eod/preopen_screener.js --paths # 打印落点
23
+ *
24
+ * 时段守卫:9:20–9:35 之外静默跳过(避免定时任务误触 / 上午空跑)。
25
+ * 开盘价(open)在 9:25 撮合后固定,9:25–9:35 抓到的 open 一致 → 调度有 5 分钟宽限。
26
+ * ---------------------------------------------------------------------------
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { execFileSync } = require('child_process');
31
+
32
+ const cfg = require('./preopen.config');
33
+ const { dayjs } = require('../time');
34
+ const { lastClosedTradingDay } = require('../rules');
35
+ const market = require('./lib/market');
36
+ const PR = require('./lib/preopen_rules');
37
+ const store = require('./lib/preopen_store');
38
+ const { buildHTML } = require('./lib/preopen_report');
39
+ const { loadIndustryMap, listedOf, sectorOf, UNKNOWN } = require('./lib/sector');
40
+ const { mapLimit } = require('./lib/http');
41
+ const { notify } = require('../notify');
42
+ const paths = require('../paths');
43
+ const scaffold = require('../scaffold');
44
+
45
+ const argv = process.argv.slice(2);
46
+ function argOf(f) { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; }
47
+ const OPT = {
48
+ universe: argv.includes('--universe') || argOf('--scope') === 'universe',
49
+ baseline: argv.includes('--baseline'),
50
+ force: argv.includes('--force'),
51
+ now: argOf('--now'),
52
+ noNotify: argv.includes('--no-notify'),
53
+ noPush: argv.includes('--no-push'),
54
+ json: argv.includes('--json'),
55
+ paths: argv.includes('--paths'),
56
+ init: argv.includes('--init'),
57
+ stamp: argv.includes('--stamp'),
58
+ };
59
+ const RUNNER = process.env.GITHUB_ACTIONS ? 'github' : 'local';
60
+ function log(m) { if (!OPT.json) console.log(m); }
61
+
62
+ function fail(msg, err) {
63
+ log(`[失败] ${msg}` + (err ? `\n${err && err.stack || err}` : ''));
64
+ if (cfg.notify.enabled && cfg.notify.alertOnFailure && !OPT.noNotify) {
65
+ notify({ title: `盘前选股失败 · ${dayjs.tz().format('MM-DD HH:mm')}`, content: `${msg}\n\n${err && err.message || err || ''}` })
66
+ .then((res) => { for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败'}`); })
67
+ .catch(() => {});
68
+ }
69
+ process.exitCode = 1;
70
+ }
71
+
72
+ // ---------- 时段守卫 ----------
73
+ function sessionState({ now, marketDate, force }) {
74
+ const from = parseInt(cfg.session.openFrom.replace(':', ''), 10);
75
+ const to = parseInt(cfg.session.openTo.replace(':', ''), 10);
76
+ const hhmm = now.hour() * 100 + now.minute();
77
+ const today = now.format('YYYY-MM-DD');
78
+ if (force) return { ok: true, reason: '--force 忽略时段', tradeDate: marketDate || today };
79
+ if (hhmm < from) return { ok: false, reason: `未到盘前窗口(${cfg.session.openFrom} 起)` };
80
+ if (hhmm > to) return { ok: false, reason: `已过盘前窗口(${cfg.session.openTo} 止,可用 --force 补跑)` };
81
+ if (marketDate && marketDate !== today) {
82
+ return { ok: false, reason: `行情日期 ${marketDate} ≠ 今日,非交易日,跳过` };
83
+ }
84
+ return { ok: true, reason: '盘前窗口内', tradeDate: marketDate || today };
85
+ }
86
+
87
+ // ---------- 观察池加载 ----------
88
+ function readCandidates() {
89
+ const f = paths.candidatesFile();
90
+ if (!fs.existsSync(f)) throw new Error('candidates.json 不存在,无法加载观察池');
91
+ const c = JSON.parse(fs.readFileSync(f, 'utf8'));
92
+ const list = (c.stocks || []).map((s) => ({ code: String(s.code), name: String(s.name || s.code), sector: String(s.sector || '未分类') }));
93
+ if (!list.length) throw new Error('candidates.json 的 stocks 为空');
94
+ return list;
95
+ }
96
+
97
+ // ---------- 基线(前一日 K 线指标)----------
98
+ async function getBaseline(codes, anchor, scope) {
99
+ if (scope === 'universe') {
100
+ const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
101
+ if (fs.existsSync(cf)) {
102
+ log(`[基线] 加载缓存 ${path.basename(cf)}`);
103
+ const m = new Map();
104
+ for (const [code, b] of Object.entries(JSON.parse(fs.readFileSync(cf, 'utf8')))) m.set(code, b);
105
+ return m;
106
+ }
107
+ if (!cfg.baseline.computeIfMissing) { log('[基线] 全市场无缓存且禁用实时计算 → 跳过趋势(P3)'); return new Map(); }
108
+ log('[基线] 全市场缓存缺失,实时计算(较慢,约数分钟)…');
109
+ }
110
+ const m = new Map();
111
+ const res = await mapLimit(codes, cfg.market.concurrency, async (code) => {
112
+ try {
113
+ const kl = await market.fetchKline(code, anchor);
114
+ return [code, PR.calcBaseline(kl, code, cfg)];
115
+ } catch (e) { return [code, { error: String((e && e.message) || e) }]; }
116
+ });
117
+ let okN = 0, errN = 0;
118
+ for (const [code, b] of res) {
119
+ if (b && !b.error) { m.set(code, b); okN++; } else errN++;
120
+ }
121
+ log(`[基线] K线 ${okN} 只成功 / ${errN} 只失败`);
122
+ if (scope === 'universe' && m.size) {
123
+ const out = {}; for (const [code, b] of m) out[code] = b;
124
+ fs.mkdirSync(path.dirname(path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor))), { recursive: true });
125
+ const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
126
+ fs.writeFileSync(cf, JSON.stringify(out), 'utf8');
127
+ log(`[基线] 已缓存 ${m.size} 只 → ${path.basename(cf)}`);
128
+ }
129
+ return m;
130
+ }
131
+
132
+ // ---------- 排除项 ----------
133
+ function excludeOne(q, map, tradeDate) {
134
+ const ex = cfg.exclude;
135
+ if (ex.st && /ST|退/i.test(q.name)) return 'ST/退市风险';
136
+ if (ex.suspended) {
137
+ const hhmmss = String(q.stamp || '').slice(8, 14);
138
+ if (!(q.vol > 0) || hhmmss === '090000') return '停牌/无成交';
139
+ }
140
+ if (ex.newListing) {
141
+ if (/^[NC]/.test(q.name)) return '新股/次新';
142
+ const listed = listedOf(map, q.code);
143
+ if (listed && tradeDate) {
144
+ const days = Math.abs(dayjs.tz(tradeDate + ' 00:00:00').diff(dayjs.tz(listed.slice(0, 10) + ' 00:00:00'), 'day'));
145
+ if (days < ex.newListingDays) return '上市过短';
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // ---------- git 推送(溯源)----------
152
+ function gitPush(files, message) {
153
+ const root = paths.PKG_ROOT;
154
+ const run = (args, allowFail) => {
155
+ try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }).toString(); }
156
+ catch (e) { if (allowFail) return ''; throw e; }
157
+ };
158
+ run(['add', ...files]);
159
+ run(['-c', 'core.hooksPath=.git/cph', 'commit', '-m', message]);
160
+ const sha = run(['rev-parse', 'HEAD']).trim();
161
+ run(['-c', 'credential.helper=', '-c', 'credential.helper=wincred', 'push', 'origin', `${sha}:refs/heads/main`]);
162
+ }
163
+
164
+ // ---------- 主流程 ----------
165
+ async function run() {
166
+ if (OPT.paths) {
167
+ const d = paths.describe();
168
+ log(`工作目录: ${d.home} (来源 ${d.source})`);
169
+ for (const [k, v] of Object.entries(d.workspace)) log(` ${k.padEnd(14)} ${v}`);
170
+ return { paths: d };
171
+ }
172
+ if (OPT.init) { scaffold.autoInit({ log, init: true }); return { init: true }; }
173
+ scaffold.autoInit({ log });
174
+
175
+ const now = OPT.now
176
+ ? dayjs.tz(OPT.now.replace(' ', 'T') + '+08:00')
177
+ : dayjs.tz();
178
+ const anchor = lastClosedTradingDay(now); // 前一日 K 线锚定日
179
+
180
+ // 仅预计算基线缓存(供全市场次日盘前用,建议收盘后定时跑)
181
+ if (OPT.baseline) {
182
+ const scope = argOf('--scope') || (OPT.universe ? 'universe' : cfg.scope.default);
183
+ let codes;
184
+ if (scope === 'universe') {
185
+ // 全市场:先抓快照枚举「活跃」代码(~5000),只对活跃票抓 K 线;
186
+ // 直接 expandCodes 会盲扫 3 万槽位(含大量 inactive),K 线逐个抓取会超时。
187
+ log('[基线] 全市场模式:先抓快照枚举活跃代码…');
188
+ const snap = await market.fullMarketSnapshot(cfg, log);
189
+ codes = snap.quotes.map((q) => q.code);
190
+ log(`[基线] 活跃代码 ${codes.length} 只(交易日 ${snap.meta.marketDate})`);
191
+ } else {
192
+ codes = readCandidates().map((c) => c.code);
193
+ }
194
+ log(`[基线预计算] scope=${scope} 共 ${codes.length} 只,锚定日 ${anchor}`);
195
+ const m = new Map();
196
+ let done = 0;
197
+ const res = await mapLimit(codes, cfg.market.concurrency, async (code) => {
198
+ try {
199
+ const kl = await market.fetchKline(code, anchor);
200
+ const b = PR.calcBaseline(kl, code, cfg);
201
+ if ((++done) % 500 === 0) log(`[基线] 进度 ${done}/${codes.length}`);
202
+ return [code, b];
203
+ } catch (e) { return [code, { error: String((e && e.message) || e) }]; }
204
+ });
205
+ let ok = 0, err = 0;
206
+ for (const [c, b] of res) { if (b && !b.error) { m.set(c, b); ok++; } else err++; }
207
+ const out = {}; for (const [c, b] of m) out[c] = b;
208
+ const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
209
+ fs.mkdirSync(path.dirname(cf), { recursive: true });
210
+ fs.writeFileSync(cf, JSON.stringify(out), 'utf8');
211
+ log(`[基线] 已缓存 ${ok} 只(失败 ${err})→ ${path.basename(cf)}`);
212
+ return { baseline: { file: cf, ok, err } };
213
+ }
214
+
215
+ // 时段守卫(本地时钟,第一遍)
216
+ let ss = sessionState({ now, force: OPT.force });
217
+ log(`[时段] ${ss.reason}`);
218
+ if (!ss.ok) { log('[结束] 跳过'); return { skipped: ss.reason }; }
219
+
220
+ // 范围与代码
221
+ const scope = argOf('--scope') || (OPT.universe ? 'universe' : cfg.scope.default);
222
+ let codes, quotes;
223
+ if (scope === 'watchlist') {
224
+ const watch = readCandidates();
225
+ codes = watch.map((w) => w.code);
226
+ log(`[1/4] 抓观察池竞价快照(${codes.length} 只)…`);
227
+ quotes = (await market.fetchBatch(codes, cfg)).filter((q) => q);
228
+ } else {
229
+ log('[1/4] 抓全市场竞价快照…');
230
+ const snap = await market.fullMarketSnapshot(cfg, log);
231
+ quotes = snap.quotes;
232
+ if (snap.meta.valid < cfg.market.minValidQuotes) throw new Error(`有效报价 ${snap.meta.valid} 低于红线 ${cfg.market.minValidQuotes}`);
233
+ codes = quotes.map((q) => q.code);
234
+ log(` 快照 ${snap.meta.valid} 只(交易日 ${snap.meta.marketDate})`);
235
+ // 第二遍时段守卫(用行情日期确认交易日)
236
+ ss = sessionState({ now, marketDate: snap.meta.marketDate, force: OPT.force });
237
+ if (!ss.ok) { log('[结束] 跳过'); return { skipped: ss.reason }; }
238
+ }
239
+ const tradeDate = ss.tradeDate;
240
+
241
+ // 基线
242
+ log('[2/4] 计算基线(昨涨 + 趋势)…');
243
+ const baseline = await getBaseline(codes, anchor, scope);
244
+
245
+ // 行业映射(分组用)
246
+ const map = loadIndustryMap();
247
+ if (map.count < 3000) log(`[警告] 行业映射仅 ${map.count} 只,分组可能失真`);
248
+
249
+ // 规则 + 打分
250
+ log('[3/4] 应用 P1/P2/P3 打分…');
251
+ const candidates = [];
252
+ const gaps = [];
253
+ for (const q of quotes) {
254
+ const b = baseline.get(q.code);
255
+ if (!b) { gaps.push({ code: q.code, name: q.name, reason: '无基线K线' }); continue; }
256
+ const exR = excludeOne(q, map, tradeDate);
257
+ if (exR) { gaps.push({ code: q.code, name: q.name, reason: exR }); continue; }
258
+
259
+ const p1 = PR.calcP1(q, b, cfg);
260
+ q.__auctionChgPct = p1.auctionChgPct;
261
+ const p2 = PR.calcP2(q, b, cfg);
262
+ const p3 = PR.calcP3(b, cfg);
263
+ const sc = PR.scorePreopen(p1, p2, p3, cfg);
264
+ if (sc.total < cfg.score.minTotal) continue;
265
+
266
+ const sector = sectorOf(map, q.code) || UNKNOWN;
267
+ candidates.push({
268
+ code: q.code, name: q.name, sector,
269
+ board: /sh68[89]/.test(q.code) ? '科创板' : /^sz30/.test(q.code) ? '创业板' : /^bj/.test(q.code) ? '北交所' : '主板',
270
+ open: q.open, prevClose: q.prevClose, price: q.price,
271
+ auctionChgPct: p1.auctionChgPct, auctionVolRatio: p1.auctionVolRatio,
272
+ yesterdayChgPct: b.yesterdayChgPct,
273
+ trend: { maBull: p3.maBull, newHigh20: p3.newHigh20, newHigh60: p3.newHigh60, slope20: p3.slope20 },
274
+ p1, p2, p3,
275
+ label: PR.labelOf(p1, p2, p3),
276
+ risk: p2.risk,
277
+ total: +sc.total.toFixed(1),
278
+ scoreItems: { p1: +p1.score.toFixed(2), p2: +p2.score.toFixed(2), p3: +p3.score.toFixed(2) },
279
+ stamp: q.stamp,
280
+ });
281
+ }
282
+ candidates.sort((a, b) => b.total - a.total);
283
+
284
+ // 分组
285
+ const bySector = new Map();
286
+ for (const c of candidates) {
287
+ if (!bySector.has(c.sector)) bySector.set(c.sector, []);
288
+ bySector.get(c.sector).push(c);
289
+ }
290
+ const groups = [];
291
+ for (const [sector, list] of bySector) {
292
+ list.sort((a, b) => b.total - a.total);
293
+ list.forEach((c, i) => { c.groupRank = i + 1; c.bestInGroup = i === 0; c.groupSize = list.length; });
294
+ groups.push({ sector, size: list.length, shown: list.slice(0, cfg.group.maxPerGroup), best: list[0] });
295
+ }
296
+ groups.sort((a, b) => b.best.total - a.best.total);
297
+
298
+ const result = {
299
+ candidates, groups, top: candidates.slice(0, cfg.group.topN),
300
+ stats: { snapshotCount: quotes.length, baselineCount: baseline.size, candidateCount: candidates.length, groupCount: groups.length, gapCount: gaps.length },
301
+ gaps,
302
+ };
303
+
304
+ log(`[4/4] 落库 + 报告 + 通知…`);
305
+ return emitResult({ tradeDate, scope, anchor, result, baselineCount: baseline.size, quotesCount: quotes.length, marketDate: tradeDate, _t0: Date.now() });
306
+ }
307
+
308
+ async function emitResult(p) {
309
+ const { tradeDate, scope, result, marketDate } = p;
310
+ const saved = store.saveRun({
311
+ cfg, tradeDate, scope, candidates: result.candidates,
312
+ meta: { snapshotCount: result.stats.snapshotCount, baselineCount: p.baselineCount, validQuotes: p.quotesCount, marketDate, elapsedMs: Date.now() - (p._t0 || Date.now()) },
313
+ marketDate,
314
+ });
315
+ log(`[落库] ${path.basename(saved.file)}(${saved.recordCount} 条 / 第 ${saved.runCount} 次运行)`);
316
+
317
+ const reportDir = path.join(paths.eodDir(), cfg.store.reportDir);
318
+ fs.mkdirSync(reportDir, { recursive: true });
319
+ const suffix = OPT.stamp ? `-${dayjs.tz().format('HHmm')}` : '';
320
+ const reportFile = path.join(reportDir, `${cfg.store.prefix}-${tradeDate}${suffix}.html`);
321
+ fs.writeFileSync(reportFile, buildHTML({ cfg, tradeDate, scope, result, marketDate, anchor: p.anchor, runner: RUNNER }), 'utf8');
322
+ log(`[报告] ${reportFile}`);
323
+
324
+ if (cfg.notify.enabled && !OPT.noNotify) {
325
+ const top = result.top.slice(0, cfg.notify.topInMessage);
326
+ const lines = top.map((c, i) =>
327
+ `${i + 1}. ${c.name}(${c.code.replace(/^[a-z]+/, '')})${c.sector} · 竞价${c.auctionChgPct?.toFixed(2)}% · 昨${c.yesterdayChgPct?.toFixed(2)}% · ${c.label} · 总分${c.total}`);
328
+ const content = [
329
+ `交易日 ${tradeDate} · 盘前选股(${scope === 'universe' ? '全市场' : '观察池'})`,
330
+ `候选 ${result.stats.candidateCount} 只 / ${result.stats.groupCount} 行业(快照 ${result.stats.snapshotCount} → 基线 ${result.stats.baselineCount})`,
331
+ '', ...lines, '',
332
+ `完整报告见本地:eod/reports/${cfg.store.prefix}-${tradeDate}${suffix}.html`,
333
+ ].join('\n');
334
+ const res = await notify({ title: `盘前选股 ${tradeDate}(${result.stats.candidateCount} 只)`, content, htmlPath: reportFile });
335
+ for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
336
+ }
337
+
338
+ // 推送远程溯源
339
+ if (!OPT.noPush) {
340
+ try {
341
+ const pushFiles = [saved.file, reportFile];
342
+ gitPush(pushFiles, `preopen: ${tradeDate} 候选 ${result.stats.candidateCount} 只(${scope})`);
343
+ log('[推送] 已 git 提交并推送远程(溯源)');
344
+ } catch (e) {
345
+ log(`[推送] 失败(数据已落本地,可稍后手动推送):${e && e.message || e}`);
346
+ }
347
+ }
348
+
349
+ const summary = {
350
+ tradeDate, scope,
351
+ snapshot: result.stats.snapshotCount, baseline: result.stats.baselineCount,
352
+ candidates: result.stats.candidateCount, groups: result.stats.groupCount,
353
+ top: result.top.slice(0, 10).map((c) => ({ code: c.code, name: c.name, total: c.total, label: c.label })),
354
+ reportFile, savedFile: saved.file,
355
+ };
356
+ if (OPT.json) console.log(JSON.stringify(summary, null, 2));
357
+ return summary;
358
+ }
359
+
360
+ run().catch((e) => fail('盘前选股流程异常', e));
@@ -42,6 +42,12 @@ module.exports = {
42
42
  earliest: '09:30',
43
43
  // 观察模式起点:≥ 14:30 允许运行(尾盘段起点已出现,可提前预览)
44
44
  observeFrom: '14:30',
45
+ // 观察模式尾盘窗口(分钟):自然窗口 [14:30, now] 不足此值时,
46
+ // 改取「now 往前 N 个成交分钟」滚动窗口(与盘中模式同思路)。
47
+ // **必须与正式口径等长(20)**:段涨幅门槛(tailSegMinPct=0.5%)与满分(2.0%)
48
+ // 都按 20 分钟标定,窗口不等长阈值即失真(实测 20min→40 只 / 30min→87 只,见 docs/ops/01-方案.md)。
49
+ // 2026-10-12 由 5 改 20:5 分钟兜底虽然能出候选,但尺子和正式口径不一致,参考价值打折。
50
+ observeMinWindow: 20,
45
51
  // 固定口径时点:≥ 14:50 后运行,一律按 14:50 截断取数。
46
52
  // 这样「上午跑一次 + 下午跑一次」得到的是**同一口径**,避免同日结果漂移(Q1 多次运行的前提)。
47
53
  cutAt: '14:50',
@@ -97,15 +103,21 @@ module.exports = {
97
103
  // 3. 初筛过滤(FR-03 / Q5–Q10)—— 尾盘口径 ≠ R01 隔夜口径,独立配置
98
104
  // =========================================================================
99
105
  filter: {
100
- // 当日涨幅区间(%)。上限若放到 9% 会引入接近涨停买不到的标的(Q5)
101
- chgPctMin: 2.0,
106
+ // 当日涨幅区间(%)。上限若放到 9% 会引入接近涨停买不到的标的(Q5)。
107
+ // 2026-10-22 回溯调参(docs/backtest/01-复盘.md):341 条候选分桶显示
108
+ // 2–3% 贴下限桶持一夜均 -1.11%、涨超 2% 命中率仅 8%(5–7% 桶 29%);
109
+ // 涨幅也是全部原始特征里与次日收益相关最强的(ρ=+0.150)。
110
+ // 故下限 2.0 → 3.0,把最弱的一段挡在门外。
111
+ chgPctMin: 3.0,
102
112
  chgPctMax: 7.0,
103
113
  // 量比下限(Q6)。14:50 的即时量比天然偏小,1.2 是「已有温和放量」的起点;
104
114
  // 想更严可提到 1.5,想放宽可降到 1.0。
105
115
  volRatioMin: 1.2,
106
- // 换手率区间(%)(Q9):过低无人气,过高警惕出货
116
+ // 换手率区间(%)(Q9):过低无人气,过高警惕出货。
117
+ // 2026-10-22 回溯调参:10–20% 桶 30 只持一夜均 -3.70%、胜率仅 17%,
118
+ // 是全表最差的一段(换手与收益 ρ=-0.141)。上限 20 → 10。
107
119
  turnoverMin: 2.0,
108
- turnoverMax: 20.0,
120
+ turnoverMax: 10.0,
109
121
  // 流通市值区间(亿)(Q10):与 candidate-pool 现有口径一致,便于横向对照
110
122
  floatCapMinYi: 20,
111
123
  floatCapMaxYi: 500,
@@ -133,29 +145,44 @@ module.exports = {
133
145
  },
134
146
 
135
147
  // =========================================================================
136
- // 5. 强度打分(FR-06 / Q11)—— 总分 100,六项可解释
148
+ // 5. 强度打分(FR-06 / Q11)—— 总分 100,五项可解释
149
+ // 2026-10-22 回溯调参(docs/backtest/01-复盘.md):
150
+ // · 删「板块强度」项:341 条候选中该项 100% 拿满 15 分(常量,零区分度),
151
+ // 还让环境组均分(32.8/35)反超个股自身组(29.1/65),分数被背景分主导;
152
+ // · 删除后余下五项按原权重比例重新分配并取整:30/20/15/10/10 → 35/24/18/12/11
153
+ // (不按相关系数加权——各分项 |ρ| 均 <0.1,那是噪声不是信号,比例分配最稳);
154
+ // · 总分本身与次日收益几乎不相关(ρ=+0.044),故新增 minTotal 只作粗过滤,
155
+ // 排序区分度请直接看涨幅(ρ=+0.150,报告已对 chg≥5% 加标记)。
137
156
  // =========================================================================
138
157
  score: {
139
158
  weights: {
140
- tailMomentum: 30, // 尾盘动能:14:30→14:50 涨幅线性映射,+2% 封顶
141
- volume: 20, // 量能:量比 1.2 → 0 分,3.0 → 满分
142
- position: 15, // 位置:距日内高点越近越高
143
- avgLine: 10, // 均价线强度:现价 ÷ 均价 的超出幅度
144
- sector: 15, // 板块强度:所属行业当日涨幅的全市场分位
145
- turnoverFit: 10, // 换手适中度:3%–10% 满分,向两端递减
159
+ tailMomentum: 35, // 尾盘动能:14:30→14:50 涨幅线性映射,+2% 封顶
160
+ volume: 24, // 量能:量比 1.2 → 0 分,3.0 → 满分
161
+ position: 18, // 位置:距日内高点越近越高
162
+ avgLine: 12, // 均价线强度:现价 ÷ 均价 的超出幅度
163
+ turnoverFit: 11, // 换手适中度:3%–7% 满分,向两端递减
146
164
  },
147
165
  // 尾盘动能:段涨幅达到该值即拿满分(%)
148
166
  tailMomentumFullPct: 2.0,
149
167
  // 量能:达到该量比即满分
150
168
  volRatioFull: 3.0,
151
- // 均价线:超出均价该百分比即满分(%)
152
- avgLineFullPct: 1.0,
153
- // 换手适中区间(%):落在此区间满分
169
+ // 均价线:超出均价该百分比即满分(%)。
170
+ // 2026-10-22 回溯调参:原 1.0% 时 89% 候选贴满分(均值 9.6/10),
171
+ // 该项已无区分度且与收益轻微负相关(ρ=-0.088)。满分线 1.0 → 1.5。
172
+ avgLineFullPct: 1.5,
173
+ // 换手适中区间(%):落在此区间满分。
174
+ // 2026-10-22 随 turnoverMax 20→10 同步由 10 → 7:
175
+ // 上限收窄后若甜区仍顶到 10%,3–10% 全满分,「过高警惕出货」的扣分就没了。
154
176
  turnoverSweetLow: 3.0,
155
- turnoverSweetHigh: 10.0,
156
- // 板块强度:行业涨幅分位映射的区间(%)。行业中位涨幅达到 hi 即满分
157
- sectorChgLoPct: 0.0,
158
- sectorChgHiPct: 2.0,
177
+ turnoverSweetHigh: 7.0,
178
+ // 候选入选总分下限(低于此不进报告)。
179
+ // 2026-10-22 回溯调参新增:旧分制下 45–55 分段 84 只持一夜均 -1.32%、命中 12%,
180
+ // 是全部分数段里最差的一段。删除 sector 常量项后总分整体下移约 7 分
181
+ // (均值 61.9 → 50.1),旧 55 的等效新总分 ≈ 47,故下限取 **47**
182
+ // (= 决策批准的切分线在新分制下的等效值;若照抄 55 会等效旧 ~59.4,
183
+ // 回溯显示边际收益仅 +1pt 命中率却再砍 36% 产量,不值)。
184
+ // 注:尾盘此前没有总分下限(初筛+分时校验过即入选),此项为新增。
185
+ minTotal: 47,
159
186
  // 分时结构加分项(不计入 100 分,单独展示,避免权重膨胀)
160
187
  // 段内上涨分钟占比低于该值时在报告中标注「尾盘拉升不连贯」
161
188
  tailUpRatioWarn: 0.55,
@@ -49,10 +49,11 @@ const fs = require('fs');
49
49
  const path = require('path');
50
50
 
51
51
  const cfg = require('./tail.config');
52
- const { sessionState, estimateFullDayVolRatio, nowBjt } = require('./lib/trading');
52
+ const { sessionState, estimateFullDayVolRatio, nowBjt, TZ } = require('./lib/trading');
53
+ const { dayjs } = require('../time');
53
54
  const market = require('./lib/market');
54
55
  const { buildCandidates } = require('./lib/screen');
55
- const { saveRun, loadDay, daySuffix } = require('./lib/store');
56
+ const { saveRun, daySuffix, saveRaw, loadRaw } = require('./lib/store');
56
57
  const { syncTailRun, cleanupLegacyModes, d1Mode, targetLabel } = require('./lib/store_d1');
57
58
  const { buildHTML } = require('./lib/report');
58
59
  const { notify } = require('../notify');
@@ -116,7 +117,37 @@ function fail(msg, err) {
116
117
  async function runReplay(file) {
117
118
  const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
118
119
  if (!doc.records || !doc.tradeDate) throw new Error('存档格式不对:缺少 records/tradeDate');
119
- // 存档只存了压缩记录,重建 result 的最小形状即可满足报告渲染
120
+
121
+ // 自包含重算:优先用同口径的原始存档(eod/data/raw/,含 quotes+minutes)→ 不抓行情,
122
+ // 用当前代码重算。历史 bug(如 14:30 整点 0 分钟窗口)能凭原始数据纠正,
123
+ // 无需重新抓已取不到的接口。原始文件夹缺失则退化为「仅重渲染已存 records」。
124
+ // 原始文件按「交易日+口径(+stamp+窗口)」命名:stamp / segMinutes 从主存档找回,
125
+ // 否则 --stamp 的当天会找错 raw(把不带时点那份的窗口数据当本次口径重算)。
126
+ const lastRun = (doc.runs && doc.runs[doc.runs.length - 1]) || {};
127
+ const raw = loadRaw(cfg, doc.tradeDate, doc.mode || 'cut', doc.cutAt || '1450',
128
+ { stamp: doc.stamp, segMinutes: lastRun.segMinutes ?? null });
129
+ if (raw && raw.quotes && raw.minutes) {
130
+ const minutesMap = new Map(Object.entries(raw.minutes).map(([code, m]) => [code, {
131
+ rows: m.rows || [], date: m.date || null, error: m.error || null,
132
+ }]));
133
+ const now = dayjs.tz(`${doc.tradeDate} ${doc.cutAt || '1450'}`, TZ);
134
+ const ssRe = sessionState({ cfg, now, marketDate: doc.tradeDate });
135
+ const result = buildCandidates({
136
+ quotes: raw.quotes, minutes: minutesMap, cfg, map: map(),
137
+ tradeDate: doc.tradeDate, cutHHMM: doc.cutAt || '1450', segFrom: ssRe.segFrom,
138
+ makeTail: (rows, code, cut, seg) => market.tailMetrics(rows, code, cut, seg),
139
+ });
140
+ log(`[回放] 从原始存档离线重算(不抓行情):${result.candidates.length} 候选 / 初筛 ${result.stats.prePassCount} 只`);
141
+ return emitResult({
142
+ tradeDate: doc.tradeDate, mode: doc.mode || 'cut', cutHHMM: doc.cutAt || '1450',
143
+ segFrom: ssRe.segFrom, segMinutes: ssRe.segMinutes ?? null, result,
144
+ meta: { slots: 0, batches: 0, failedBatches: 0, valid: raw.quotes.length, marketDate: doc.tradeDate, elapsedMs: 0, outlierDates: [] },
145
+ quotes: raw.quotes, minutes: minutesMap, _t0: Date.now(), skipRaw: true,
146
+ stamp: doc.stamp || false,
147
+ });
148
+ }
149
+
150
+ // 旧主存档(无原始数据 / 原始文件夹缺失):仅重渲染已存的 records(渲染只读,不动存档)
120
151
  const candidates = Object.values(doc.records).map((r) => ({
121
152
  ...r,
122
153
  tail: r.tail || null,
@@ -141,11 +172,16 @@ async function runReplay(file) {
141
172
  stats: doc.stats || {},
142
173
  dropped: [],
143
174
  };
175
+ // 口径行用存档里真实记录的 segFrom(盘中/观察的滚动起点),不要回退到 cfg 默认 14:30 ——
176
+ // 否则旧盘中存档重渲染会显示错误的口径区间。
177
+ const recsRe = Object.values(doc.records);
178
+ const segFromRe = (recsRe[0] && recsRe[0].tail && recsRe[0].tail.segFrom) || lastRun.segFrom
179
+ || cfg.session.observeFrom.replace(':', '');
144
180
  return emitResult({
145
181
  tradeDate: doc.tradeDate, mode: doc.mode || 'cut', cutHHMM: doc.cutAt || '1450',
146
- segFrom: cfg.session.observeFrom.replace(':', ''), result,
182
+ segFrom: segFromRe, result,
147
183
  meta: { slots: 0, batches: 0, failedBatches: 0, valid: candidates.length, marketDate: doc.tradeDate, elapsedMs: 0, outlierDates: [] },
148
- skipStore: true,
184
+ skipStore: true, stamp: doc.stamp || false,
149
185
  });
150
186
  }
151
187
 
@@ -286,6 +322,7 @@ async function run() {
286
322
  return emitResult({
287
323
  tradeDate: ss.tradeDate, mode: ss.mode, cutHHMM, segFrom,
288
324
  segMinutes: ss.segMinutes ?? null, result, meta: snap.meta, _t0: t0,
325
+ quotes: snap.quotes, minutes,
289
326
  });
290
327
  }
291
328
 
@@ -301,17 +338,27 @@ function map() {
301
338
 
302
339
  /** 落库 + 报告 + 通知(live 与 replay 共用) */
303
340
  async function emitResult(p) {
304
- const { tradeDate, mode, cutHHMM, segFrom, segMinutes, result, meta } = p;
341
+ const { tradeDate, mode, cutHHMM, segFrom, segMinutes, result, meta, quotes, minutes, skipRaw } = p;
342
+ // stamp:live 跑用 CLI 的 OPT.stamp;replay 时由 runReplay 传入存档里的 doc.stamp,
343
+ // 否则 replay 带 --stamp 的存档会把主存档/报告写到不带时点的文件名上(互相覆盖)。
344
+ const stamp = p.stamp ?? OPT.stamp;
305
345
 
306
346
  // 落库
307
347
  let saved = null;
308
348
  if (!p.skipStore) {
309
349
  saved = saveRun({
310
350
  cfg, tradeDate, mode, cutHHMM, result, runner: RUNNER,
311
- segFrom, segMinutes, stamp: OPT.stamp,
351
+ segFrom, segMinutes, stamp,
312
352
  });
313
353
  log(`[落库] ${path.basename(saved.file)}(${saved.recordCount} 条 / 第 ${saved.runCount} 次运行)`);
314
354
 
355
+ // 原始快照单独落 eod/data/raw/(供 --replay 离线重算,gitignore 不入库);
356
+ // replay 离线路径已持有原始数据,不必重写 → skipRaw。
357
+ if (!skipRaw && quotes && minutes) {
358
+ const raw = saveRaw({ cfg, tradeDate, mode, cutHHMM, quotes, minutes, stamp, segMinutes });
359
+ if (raw) log(`[原始存档] ${path.basename(raw)}(供 --replay 离线重算,不进 git)`);
360
+ }
361
+
315
362
  // 同步到数据源(网页 /api/tail/* 读 D1;本地 db/local.db 供离线复盘)。
316
363
  // 目标见 store_d1.js::targetArgv():**本机跑默认两边都写,GitHub Actions 只写远程**;
317
364
  // `--local` / `--both` 可显式覆盖。--no-d1(盘中模式强制)则完全不写库。
@@ -331,7 +378,7 @@ async function emitResult(p) {
331
378
  // 报告
332
379
  const reportDir = path.join(paths.eodDir(), cfg.store.reportDir);
333
380
  fs.mkdirSync(reportDir, { recursive: true });
334
- const suffix = daySuffix(mode, cutHHMM, { stamp: OPT.stamp, segMinutes });
381
+ const suffix = daySuffix(mode, cutHHMM, { stamp, segMinutes });
335
382
  const reportFile = path.join(reportDir, `eod-${tradeDate}${suffix}.html`);
336
383
  fs.writeFileSync(reportFile, buildHTML({
337
384
  cfg, tradeDate, mode, cutHHMM, segFrom, result, meta, runner: RUNNER,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candidate-pool",
3
- "version": "2.1.1",
3
+ "version": "3.0.0",
4
4
  "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
5
  "bin": {
6
6
  "candidate-pool": "bin/candidate-pool.js",
@@ -53,6 +53,10 @@
53
53
  "tail:intraday": "node eod/tail_screener.js --intraday",
54
54
  "tail:replay": "node eod/tail_screener.js --replay",
55
55
  "tail:selftest": "node eod/lib/trading.selftest.js",
56
+ "preopen": "node eod/preopen_screener.js",
57
+ "preopen:watchlist": "node eod/preopen_screener.js --scope watchlist",
58
+ "preopen:baseline": "node eod/preopen_screener.js --baseline",
59
+ "preopen:backfill": "node eod/preopen_backfill.js",
56
60
  "paths": "node gen_candidates.js --paths",
57
61
  "paths:selftest": "node paths.selftest.js",
58
62
  "init": "node gen_candidates.js --init",