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 +29 -0
- package/eod/lib/report.js +60 -11
- package/eod/lib/store.js +48 -1
- package/eod/lib/trading.js +14 -1
- package/eod/lib/trading.selftest.js +56 -2
- package/eod/tail.config.js +6 -0
- package/eod/tail_screener.js +55 -8
- package/package.json +1 -1
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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 };
|
package/eod/lib/trading.js
CHANGED
|
@@ -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=
|
|
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=
|
|
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);
|
package/eod/tail.config.js
CHANGED
|
@@ -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',
|
package/eod/tail_screener.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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,
|