candidate-pool 2.1.1 → 2.2.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 CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  所有变动按 **Conventional Commits** 约定分类,由 `changelogen` 自动生成。
4
4
 
5
+ ## v2.2.0
6
+
7
+ [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.1.1...v2.2.0)
8
+
9
+ ### 🚀 新功能 (Features)
10
+
11
+ - **eod:** 尾盘报告过滤新增「只看组内第一」与「组内前三」 ([b07bffa](https://github.com/GuoSirius/candidate-pool/commit/b07bffa))
12
+
13
+ ### 🐛 缺陷修复 (Bug Fixes)
14
+
15
+ - 修复观察模式整点触发尾盘段退化 0 分钟窗口导致 100% 0 候选 ([7eed01f](https://github.com/GuoSirius/candidate-pool/commit/7eed01f))
16
+ - 用新逻辑重跑 2026-10-12 观察模式,纠正 0 候选存档(4 只) ([e4798e1](https://github.com/GuoSirius/candidate-pool/commit/e4798e1))
17
+ - **eod:** 原始快照(quotes+minutes)单独存 eod/data/raw 供 --replay 离线重算 ([310e0ac](https://github.com/GuoSirius/candidate-pool/commit/310e0ac))
18
+ - **eod:** 观察窗口 observeMinWindow 5→20(与正式口径等长),审计修复 replay 细节 ([8684db9](https://github.com/GuoSirius/candidate-pool/commit/8684db9))
19
+
20
+ ### 📦 杂项维护 (Chores)
21
+
22
+ - 尾盘观察 2026-10-09 ([4d63d5d](https://github.com/GuoSirius/candidate-pool/commit/4d63d5d))
23
+ - 尾盘选股 2026-10-09 ([c16b81d](https://github.com/GuoSirius/candidate-pool/commit/c16b81d))
24
+ - **eod:** 用新过滤重建 10-09 三份尾盘报告 ([4511ca4](https://github.com/GuoSirius/candidate-pool/commit/4511ca4))
25
+ - 每日实时初筛 2026-10-09 ([482d84c](https://github.com/GuoSirius/candidate-pool/commit/482d84c))
26
+ - 尾盘观察 2026-10-12 ([c85600f](https://github.com/GuoSirius/candidate-pool/commit/c85600f))
27
+ - 尾盘选股 2026-10-12 ([ccf7d1f](https://github.com/GuoSirius/candidate-pool/commit/ccf7d1f))
28
+ - 每日实时初筛 2026-10-12 ([103593f](https://github.com/GuoSirius/candidate-pool/commit/103593f))
29
+
30
+ ### ❤️ Contributors
31
+
32
+ - 郭之存 ([@siriusSupreme](https://github.com/siriusSupreme))
33
+
5
34
  ## v2.1.1
6
35
 
7
36
  [compare changes](https://github.com/GuoSirius/candidate-pool/compare/v2.1.0...v2.1.1)
package/eod/lib/report.js CHANGED
@@ -82,7 +82,7 @@ function rowHtml(c, i, cfg, withBreakdown) {
82
82
  const cells = withBreakdown
83
83
  ? SCORE_COLS.map(([k], idx) => `<td class="${SCORE_CELL_CLS[idx]}">${num(c.score ? c.score[k] : null, 1)}</td>`).join('')
84
84
  : '';
85
- return `<tr data-sector="${esc(c.sector)}">
85
+ return `<tr data-sector="${esc(c.sector)}"${c.bestInGroup ? ' data-best="1"' : ''} data-grank="${c.groupRank || ''}">
86
86
  <td>${i + 1}</td>
87
87
  <td><b>${esc(c.name)}</b><span class="src">${c.code.replace(/^[a-z]+/, '')}</span></td>
88
88
  <td>${b(esc(c.sector), esc(c.sector))}</td>
@@ -225,12 +225,24 @@ function buildHTML(m) {
225
225
  const { candidates, groups, top, stats } = result;
226
226
  // 三种口径必须在报告顶部一眼可辨:盘中模式的「尾盘段」是滚动窗口近似,
227
227
  // 若标签写得像正式结果,复盘时会把近似值当基准(口径混淆)。
228
- const modeLabel = mode === 'observe'
229
- ? b(`观察模式(口径 ${cutHHMM},未固定)`, `observe mode (cut ${cutHHMM}, not final)`)
230
- : mode === 'intraday'
231
- ? b(`盘中模式(口径 ${cutHHMM},尾盘段=最近窗口近似 · 非正式结果)`,
232
- `intraday mode (cut ${cutHHMM}, rolling-window approximation — NOT final)`)
233
- : b(`固定口径 ${cutHHMM}`, `fixed cut ${cutHHMM}`);
228
+ // 观察模式整点触发时自然窗口退化为 0 分钟 → trading.js 改用最近 observeMinWindow 分钟滚动窗口
229
+ // (segFrom != 1430)。此时必须把「非正式」写进标签,否则复盘会把滚动窗口当 14:30 正式尾盘
230
+ // (口径混淆,与盘中模式同一条红线)。
231
+ let modeLabel;
232
+ if (mode === 'observe' && segFrom !== '1430') {
233
+ const cutMin = (+cutHHMM.slice(0, 2)) * 60 + (+cutHHMM.slice(2, 4));
234
+ const segMin = (+segFrom.slice(0, 2)) * 60 + (+segFrom.slice(2, 4));
235
+ const win = cutMin - segMin;
236
+ modeLabel = b(`观察模式(按最近 ${win} 分钟滚动窗口,与正式口径等长 · 非正式)`,
237
+ `observe mode (rolling ${win}-min window, same length as formal — NOT final)`);
238
+ } else if (mode === 'observe') {
239
+ modeLabel = b(`观察模式(口径 ${cutHHMM},未固定)`, `observe mode (cut ${cutHHMM}, not final)`);
240
+ } else if (mode === 'intraday') {
241
+ modeLabel = b(`盘中模式(口径 ${cutHHMM},尾盘段=最近窗口近似 · 非正式结果)`,
242
+ `intraday mode (cut ${cutHHMM}, rolling-window approximation — NOT final)`);
243
+ } else {
244
+ modeLabel = b(`固定口径 ${cutHHMM}`, `fixed cut ${cutHHMM}`);
245
+ }
234
246
 
235
247
  // ---- 核心结论 ----
236
248
  const bestGroup = groups[0];
@@ -272,7 +284,15 @@ function buildHTML(m) {
272
284
  for (const c of listed) { const k = c.sector || '未分类'; chipCounts[k] = (chipCounts[k] || 0) + 1; }
273
285
  const chipOrder = groups.map((g) => g.sector).filter((s) => chipCounts[s]);
274
286
  for (const k of Object.keys(chipCounts)) if (!chipOrder.includes(k)) chipOrder.push(k);
287
+ // 「只看组内第一」/「组内前三」:跨行业挑更强用的伪过滤值(@best / @top3),
288
+ // 组内排名来自 groupAndRank 的 groupRank(组内按总分降序)。
289
+ const bestCount = listed.reduce((n, c) => n + (c.bestInGroup ? 1 : 0), 0);
290
+ const top3Count = listed.reduce((n, c) => n + ((c.groupRank || 99) <= 3 ? 1 : 0), 0);
275
291
  const chipsHtml = [`<button type="button" class="chip active" data-filter="">${b(`全部 ${listed.length}`, `All ${listed.length}`)}</button>`]
292
+ .concat([
293
+ `<button type="button" class="chip" data-filter="@best">${b('只看组内第一', 'Group #1 only')}<i>${bestCount}</i></button>`,
294
+ `<button type="button" class="chip" data-filter="@top3">${b('组内前三', 'Group top 3')}<i>${top3Count}</i></button>`,
295
+ ])
276
296
  .concat(chipOrder.map((s) =>
277
297
  `<button type="button" class="chip" data-filter="${esc(s)}">${b(esc(s), esc(s))}<i>${chipCounts[s]}</i></button>`))
278
298
  .join('');
@@ -420,20 +440,47 @@ function buildHTML(m) {
420
440
  var viewSectorBtn = document.getElementById('viewSector');
421
441
  function renderCand() {
422
442
  var frag = document.createDocumentFragment();
443
+ var bestOnly = (candFilter === '@best');
444
+ var top3 = (candFilter === '@top3');
445
+ var ranked = bestOnly || top3; // 过滤视图下 # 列临时改显组内排名
446
+ // 行筛选:@best 只留组内第一;@top3 留组内排名 1-3;其余按行业 chip 过滤
447
+ function want(tr) {
448
+ if (bestOnly) return tr.hasAttribute('data-best');
449
+ if (top3) {
450
+ var g = parseInt(tr.getAttribute('data-grank'), 10);
451
+ return g >= 1 && g <= 3;
452
+ }
453
+ return !candFilter || tr.getAttribute('data-sector') === candFilter;
454
+ }
455
+ var shown = [];
423
456
  if (candView === 'score') {
424
457
  candRows.forEach(function (tr) {
425
- if (!candFilter || tr.getAttribute('data-sector') === candFilter) frag.appendChild(tr);
458
+ if (want(tr)) { frag.appendChild(tr); shown.push(tr); }
426
459
  });
427
460
  } else {
428
461
  sepOrder.forEach(function (sep) {
429
462
  var sec = sep.getAttribute('data-sep');
430
- if (candFilter && sec !== candFilter) return;
431
- sep.hidden = false; frag.appendChild(sep);
463
+ if (!bestOnly && !top3 && candFilter && sec !== candFilter) return;
464
+ // 先收集该组本次要显示的行:空组(如组内第一被 maxRows 截断)不渲染分隔行
465
+ var groupRows = [];
432
466
  candRows.forEach(function (tr) {
433
- if (tr.getAttribute('data-sector') === sec) frag.appendChild(tr);
467
+ if (tr.getAttribute('data-sector') === sec && want(tr)) groupRows.push(tr);
434
468
  });
469
+ if (!groupRows.length) return;
470
+ sep.hidden = false; frag.appendChild(sep);
471
+ groupRows.forEach(function (tr) { frag.appendChild(tr); shown.push(tr); });
435
472
  });
436
473
  }
474
+ // # 列:过滤视图显示组内排名(1/2/3…),切回时恢复原始序号
475
+ shown.forEach(function (tr) {
476
+ var cell = tr.cells[0];
477
+ if (ranked) {
478
+ if (!tr.dataset.idx) tr.dataset.idx = cell.textContent;
479
+ cell.textContent = tr.getAttribute('data-grank') || tr.dataset.idx;
480
+ } else if (tr.dataset.idx) {
481
+ cell.textContent = tr.dataset.idx;
482
+ }
483
+ });
437
484
  while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
438
485
  tbody.appendChild(frag);
439
486
  }
@@ -452,6 +499,8 @@ function buildHTML(m) {
452
499
  Array.prototype.forEach.call(document.querySelectorAll('#chips .chip'), function (c) {
453
500
  c.classList.toggle('active', (c.getAttribute('data-filter') || '') === candFilter);
454
501
  });
502
+ // 组内前三要求同组相邻:选中时自动切到「按行业」视图(用户仍可手动切回按总分)
503
+ if (candFilter === '@top3' && candView !== 'sector') { setView('sector'); return; }
455
504
  renderCand();
456
505
  });
457
506
  var bdToggle = document.getElementById('bdToggle');
package/eod/lib/store.js CHANGED
@@ -21,6 +21,15 @@ const path = require('path');
21
21
  const { now: nowBJ } = require('../../time');
22
22
  const paths = require('../../paths');
23
23
 
24
+ /** Map<code,{rows,date,error}> → 纯对象(存档用,JSON 可序列化) */
25
+ function minutesToObj(map) {
26
+ const o = {};
27
+ for (const [code, m] of map) {
28
+ o[code] = { rows: m.rows || null, date: m.date || null, error: m.error || null };
29
+ }
30
+ return o;
31
+ }
32
+
24
33
  /** 候选对象 -> 存档记录(压缩字段:分时明细不落盘,只留尾盘段汇总) */
25
34
  function toRecord(c) {
26
35
  return {
@@ -167,15 +176,53 @@ function saveRun({ cfg, tradeDate, mode, cutHHMM, result, runner, segFrom = null
167
176
  tradeDate,
168
177
  mode,
169
178
  cutAt: cutHHMM,
179
+ // 是否 --stamp 落盘(带时点文件名):--replay 靠它找回对应的原始快照
180
+ // (--stamp 的当天可能同时存在带时点与不带时点的 raw 文件,缺此字段会找错)。
181
+ stamp: stamp || undefined,
170
182
  updatedAt: nowBJ(),
171
183
  runs,
172
184
  stats: result.stats,
173
185
  records,
186
+ // 原始快照(quotes/minutes)不进主存档:体积大(全市场 ~6MB/日),
187
+ // 单独存 eod/data/raw/ 并 gitignore,仅供 --replay 离线重算(见 saveRaw)。
188
+ // 主存档保持精简 records,按原约定提交 git。
174
189
  };
175
190
  fs.writeFileSync(file, JSON.stringify(doc, null, 1), 'utf8');
176
191
  return { file, doc, recordCount: Object.keys(records).length, runCount: runs.length, replacedFill };
177
192
  }
178
193
 
194
+ /**
195
+ * 原始数据存档(供 --replay 离线重算,无需重新抓行情):
196
+ * quotes = 全市场快照(初筛 preFilter + 板块强度统计用)
197
+ * minutes = 初筛通过票的分时 K 线(Map<code,{rows,date,error}>)
198
+ * 与主存档同「交易日+口径」命名,落到 eod/data/raw/(.raw.json),
199
+ * 体积大(全市场 ~6MB/日)→ .gitignore 忽略、不进 git;换机器靠 D1 库。
200
+ * 主存档缺原始文件时,runReplay 退化为「仅重渲染已存 records」。
201
+ */
202
+ function rawFile(cfg, tradeDate, mode, cutHHMM, opts) {
203
+ return path.join(paths.eodDir(), cfg.store.dataDir, 'raw', `eod-${tradeDate}${daySuffix(mode, cutHHMM, opts)}.raw.json`);
204
+ }
205
+ function saveRaw({ cfg, tradeDate, mode, cutHHMM, quotes, minutes, stamp = false, segMinutes = null }) {
206
+ if (!quotes || !minutes) return null;
207
+ const dir = path.join(paths.eodDir(), cfg.store.dataDir, 'raw');
208
+ fs.mkdirSync(dir, { recursive: true });
209
+ const doc = {
210
+ version: cfg.version,
211
+ tradeDate, mode, cutAt: cutHHMM, stamp,
212
+ savedAt: nowBJ(),
213
+ quotes,
214
+ minutes: minutesToObj(minutes),
215
+ };
216
+ const file = rawFile(cfg, tradeDate, mode, cutHHMM, { stamp, segMinutes });
217
+ fs.writeFileSync(file, JSON.stringify(doc), 'utf8');
218
+ return file;
219
+ }
220
+ function loadRaw(cfg, tradeDate, mode, cutHHMM, opts) {
221
+ const f = rawFile(cfg, tradeDate, mode, cutHHMM, opts);
222
+ if (!fs.existsSync(f)) return null;
223
+ try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return null; }
224
+ }
225
+
179
226
  /**
180
227
  * 回填(P5):给指定交易日的正式文件写 fill 字段。
181
228
  * @param {object} patch code -> fill 对象(整体替换该 code 的 fill)
@@ -202,4 +249,4 @@ function applyFill(cfg, tradeDate, patch) {
202
249
  return { updated, missing };
203
250
  }
204
251
 
205
- module.exports = { saveRun, loadDay, applyFill, toRecord, dayFile, daySuffix };
252
+ module.exports = { saveRun, loadDay, applyFill, toRecord, dayFile, daySuffix, saveRaw, loadRaw, rawFile };
@@ -228,10 +228,23 @@ function sessionState({ cfg, now = null, marketDate = null, force = false, intra
228
228
  }
229
229
 
230
230
  // 14:30 ≤ now < 14:50 —— 观察模式
231
+ // 尾盘段取「now 往前 observeMinWindow(默认 20)个成交分钟」的**滚动窗口**(复用 backTradedMinutes):
232
+ // · 14:30 整点触发时自然窗口 [14:30, now] 退化为 0 分钟 → segPct=0 → 全部撞闸(2026-10-12 修复);
233
+ // · 更根本的:段涨幅门槛与满分都按 20 分钟标定,窗口不等长阈值即失真
234
+ // (实测 20min→40 只 / 30min→87 只),故观察段一律与正式口径等长,
235
+ // 结果才可与 14:50 正式结果横向对比。自然窗口恒 < 20 → 14:30–14:49 恒为滚动。
236
+ const obsMinWin = Math.max(1, Math.round(s.observeMinWindow || 20));
237
+ const obsCut = clampToTradedMinute(M);
238
+ const naturalWin = M - hhmmToMin(s.observeFrom);
239
+ const segFromObs = (naturalWin >= obsMinWin)
240
+ ? segFromDefault
241
+ : fmtMin(backTradedMinutes(obsCut, obsMinWin)).replace(':', '');
231
242
  return {
232
243
  ...base, ok: true, mode: 'observe', tradeDate: today,
233
- reason: `观察模式:未到 ${s.cutAt},按当前时点 ${d.format('HH:mm')} 取数(口径会变,结果仅供参考)`,
244
+ reason: `观察模式:未到 ${s.cutAt},按当前时点 ${d.format('HH:mm')} 取数(口径会变,结果仅供参考)`
245
+ + (segFromObs !== segFromDefault ? `;尾盘段用最近 ${obsMinWin} 分钟滚动窗口(与正式口径等长,口径未固定)` : ''),
234
246
  cutTime: d.format('HH:mm'), cutMinutes: M,
247
+ segFrom: segFromObs,
235
248
  elapsed: elapsedTradingMinutes(M),
236
249
  estimateFullDay: false,
237
250
  };
@@ -96,10 +96,21 @@ check('正式/观察口径 segMinutes 为 undefined(不是滚动窗口)',
96
96
  console.log('\n[3] 盘中模式不夺权:14:30 之后仍走观察/正式口径');
97
97
  const o1435 = run({ now: at('14:35') });
98
98
  check('14:35 无参数 → observe', o1435.mode === 'observe', o1435.mode);
99
- check('14:35 无参数 → segFrom=1430(固定起点)', o1435.segFrom === '1430', o1435.segFrom);
99
+ check('14:35 无参数 → segFrom=1415(自然窗口 5 < 20 → 滚动)', o1435.segFrom === '1415', o1435.segFrom);
100
100
  const i1435 = run({ now: at('14:35'), intraday: true });
101
101
  check('14:35 加 --intraday → 仍 observe(开关无副作用)', i1435.mode === 'observe', i1435.mode);
102
- check('14:35 加 --intraday → 仍 observe 且 segFrom=1430', i1435.segFrom === '1430', i1435.segFrom);
102
+ check('14:35 加 --intraday → 仍 observe 且 segFrom=1415', i1435.segFrom === '1415', i1435.segFrom);
103
+
104
+ console.log('\n[3b] 观察模式滚动窗口(2026-10-12 修复的 0 候选 bug + 尺子对齐)');
105
+ // 14:30 整点:自然窗口 [14:30,14:30]=0 分钟 → segPct=0 → 全部撞「尾盘段涨幅不足」闸
106
+ // (实测 14:30 截断日 100% 0 候选)。且段涨幅门槛按 20 分钟标定,窗口不等长阈值即失真,
107
+ // 故观察段一律用 observeMinWindow(=20) 滚动窗口 —— 与正式口径等长,结果才可横向对比。
108
+ const o1430 = run({ now: at('14:30') });
109
+ check('14:30 无参数 → observe', o1430.mode === 'observe', o1430.mode);
110
+ check('14:30 整点 → segFrom=1410(滚动 20 分钟)', o1430.segFrom === '1410', o1430.segFrom);
111
+ check('14:30 整点 → reason 提示滚动窗口', /滚动/.test(o1430.reason), o1430.reason);
112
+ check('14:31 → segFrom=1411(滚动 20 分钟)', run({ now: at('14:31') }).segFrom === '1411');
113
+ check('14:35 → segFrom=1415(自然窗口 5 < 20,仍滚动)', run({ now: at('14:35') }).segFrom === '1415');
103
114
 
104
115
  const c1455 = run({ now: at('14:55') });
105
116
  check('14:55 → cut', c1455.mode === 'cut', c1455.mode);
@@ -174,6 +185,17 @@ for (const t of ['09:40', '10:30', '11:20', '11:45', '12:30', '13:00', '13:05',
174
185
  tm ? `segPct=${tm.segPct.toFixed(2)}% bars=${tm.bars}` : 'null(会被判 data_gap 剔除)');
175
186
  }
176
187
 
188
+ console.log('\n[8b] 观察模式整点:尾盘段必须能算出真实 segPct(0 候选 bug 的端到端证明)');
189
+ // 修复前:14:30 整点 segFrom=cutTime=1430 → p0/p1 同根 → segPct=0 → 100% 撞闸。
190
+ // 修复后:segFrom 退到 1410(滚动 20 分钟窗口),段指标非退化,segPct > 0。
191
+ {
192
+ const ss = run({ now: at('14:30') });
193
+ const tm = tailMetrics(rows, 'sz000001', ss.cutTime.replace(':', ''), ss.segFrom);
194
+ check('14:30 observe → tailMetrics 非 null(起点 K 线存在)', tm !== null, tm ? '' : 'null');
195
+ check('14:30 observe → segPct > 0(不再是退化的 0 分钟窗口)', tm && tm.segPct > 0,
196
+ tm ? `segPct=${tm.segPct.toFixed(3)}% bars=${tm.bars} 窗口 ${ss.segFrom}→${ss.cutTime}` : 'null');
197
+ }
198
+
177
199
  console.log('\n[9] 落库口径:内部口径必须翻译成 D1/API 的 formal / observe');
178
200
  // 历史 bug(2026-09-21):本地内部把「14:50 固定口径」叫 `cut`,落库时原样写进 D1 →
179
201
  // · 记录页把正式运行显示成「观察」(前端 else 兜底);
@@ -280,5 +302,37 @@ console.log('\n[11] 落库文件名:观察 / 盘中默认一天一个(不带
280
302
  dayFile(cfg, '2026-09-21', 'observe', '1431', { stamp: true }) !== dayFile(cfg, '2026-09-21', 'observe', '1445', { stamp: true }));
281
303
  }
282
304
 
305
+ console.log('\n[12] 原始快照存档(saveRaw/loadRaw):与主存档同口径命名,replay 靠 doc.stamp/segMinutes 找回');
306
+ {
307
+ const fs = require('fs');
308
+ const { saveRaw, loadRaw, rawFile } = require('./store');
309
+ const cfgRaw = { version: 1, store: { dataDir: 'data' } };
310
+ const D = '1970-01-01'; // 假日期,不与真实存档冲突;测完即删
311
+
312
+ const norm = (p) => String(p).replace(/\\/g, '/'); // Windows path.join 产生反斜杠
313
+ check('raw 命名:观察 → raw/eod-<日>-obs.raw.json',
314
+ norm(rawFile(cfgRaw, D, 'observe', '1430')).endsWith(`raw/eod-${D}-obs.raw.json`));
315
+ check('raw 命名:盘中 + stamp 带时点与窗口',
316
+ norm(rawFile(cfgRaw, D, 'intraday', '1310', { stamp: true, segMinutes: 20 })).endsWith(`raw/eod-${D}-intraday1310-w20.raw.json`));
317
+ check('saveRaw:缺 quotes/minutes 直接返回 null(不写文件)',
318
+ saveRaw({ cfg: cfgRaw, tradeDate: D, mode: 'cut', cutHHMM: '1450' }) === null);
319
+
320
+ const saved = saveRaw({
321
+ cfg: cfgRaw, tradeDate: D, mode: 'observe', cutHHMM: '1430',
322
+ quotes: [{ code: 'sh600000' }],
323
+ minutes: new Map([['sh600000', { rows: [[1, 2, 3, 4]], date: '19700101', error: null }]]),
324
+ });
325
+ try {
326
+ check('saveRaw 往返:loadRaw 读回 quotes/minutes', (() => {
327
+ const d = loadRaw(cfgRaw, D, 'observe', '1430');
328
+ return !!d && d.quotes.length === 1 && !!d.minutes.sh600000 && d.minutes.sh600000.rows.length === 1;
329
+ })());
330
+ check('loadRaw:stamp 不匹配 → null(--stamp 当天不会找错 raw)',
331
+ loadRaw(cfgRaw, D, 'observe', '1430', { stamp: true }) === null);
332
+ } finally {
333
+ if (saved) { try { fs.unlinkSync(saved); } catch (_) { /* 忽略 */ } }
334
+ }
335
+ }
336
+
283
337
  console.log(failed ? `\n自我检查失败:${failed} 项` : '\n全部通过');
284
338
  process.exit(failed ? 1 : 0);
@@ -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',
@@ -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": "2.2.0",
4
4
  "description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
5
5
  "bin": {
6
6
  "candidate-pool": "bin/candidate-pool.js",