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.
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);
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/preopen.config.js —— 盘前选股(Pre-Open Screener)全部可调参数
4
+ * ---------------------------------------------------------------------------
5
+ * 设计原则与 tail.config.js 一致:纯数据、改参数不改代码、每个阈值写明口径。
6
+ *
7
+ * 与尾盘选股的区别(盘前特有的难点):
8
+ * - 数据窗口只有 9:15–9:30 的「集合竞价」,价格字段用 今开(open) 近似竞价撮合价;
9
+ * 9:20 后不可撤单段才可靠,故运行窗口设为 09:20–09:35(open 在 9:25 撮合后固定)。
10
+ * - 趋势 / 昨日涨幅无法从竞价快照得到,必须依赖「前一日 K 线」——由 baseline 提供
11
+ * (观察池实时抓 K 线算,全市场用隔夜缓存 baseline-YYYYMMDD.json)。
12
+ * - 「预期」锚定 = 昨日强度(昨日涨幅)+ 板块强度,纯数据可自动化,不碰消息面。
13
+ *
14
+ * 数据源(复用现有):
15
+ * - 竞价快照:腾讯 qt.gtimg.cn 批量报价(open/现价/成交量/昨收)
16
+ * - 历史 K 线:腾讯 ifzq fqkline(前复权),与 gen_candidates.js 同链
17
+ * ---------------------------------------------------------------------------
18
+ */
19
+
20
+ // 复用尾盘的代码段定义(盲扫枚举用,避免两处维护)
21
+ const TAIL = require('./tail.config');
22
+
23
+ module.exports = {
24
+ version: 1,
25
+
26
+ // =========================================================================
27
+ // 1. 交易时段(盘前集合竞价窗口)
28
+ // =========================================================================
29
+ session: {
30
+ // 可运行的最早时点:9:20 后不可撤单段数据才可靠(9:15–9:20 可撤单噪声大)
31
+ openFrom: '09:20',
32
+ // 最晚时点:9:35 留 5 分钟宽限(open 在 9:25 撮合后即固定,9:25–9:35 抓到的 open 一致)
33
+ openTo: '09:35',
34
+ // 锚定日 = 最近一个已收盘交易日(lastClosedTradingDay),K 线取至此日
35
+ },
36
+
37
+ // =========================================================================
38
+ // 2. 行情抓取(竞价快照)
39
+ // =========================================================================
40
+ market: {
41
+ batch: 60, // 与尾盘一致
42
+ concurrency: 6,
43
+ retries: 4,
44
+ timeoutMs: 15000,
45
+ ranges: TAIL.market.ranges, // 复用:沪主板/科创/深主/创业/北交所
46
+ excludePrefixes: ['sh900', 'sz200'],
47
+ // 观察池模式数量小,红线可低;全市场模式应 >= 3000
48
+ minValidQuotes: 50,
49
+ staleMinutes: 45,
50
+ },
51
+
52
+ // =========================================================================
53
+ // 3. 范围
54
+ // default: 'universe'(默认,全市场盲扫,覆盖最全、信号最准;需 baseline 缓存,见 baseline 段)
55
+ // 可显式 --scope watchlist 回退到观察池(扫 candidates.json,轻量稳、不依赖缓存)
56
+ // 生效优先级:--scope <x> 显式指定 > --universe 简写 > scope.default
57
+ // =========================================================================
58
+ scope: {
59
+ default: 'universe',
60
+ },
61
+
62
+ // =========================================================================
63
+ // 4. 基线(前一日 K 线指标缓存)—— P02/P03 的数据源
64
+ // =========================================================================
65
+ baseline: {
66
+ dataDir: 'data',
67
+ // 缓存文件名模板(按交易日):preopen-baseline-YYYYMMDD.json
68
+ fileName: (date) => `preopen-baseline-${date}.json`,
69
+ // 全市场模式若缓存缺失则实时算(慢,~3min);观察池模式默认实时算(几百只,~30s)
70
+ computeIfMissing: true,
71
+ },
72
+
73
+ // =========================================================================
74
+ // 5. 信号阈值(P01 竞价强势 / P02 弱转强 / P03 趋势)
75
+ // =========================================================================
76
+ rules: {
77
+ // ---- P01 竞价强势 ----
78
+ p1: {
79
+ // 竞价涨幅(今开 vs 昨收)"高开"起点(%);低于此不算强势开盘
80
+ auctionChgMin: 3.0,
81
+ // 近一字 / 涨停竞价(%):达到即封板预期
82
+ auctionChgLimit: 9.0,
83
+ // 竞价量能比下限 = 竞价成交量 / 昨日全天成交量(无单位,同口径)。
84
+ // 强竞价常见 3%–10%;低于 2% 视为缩量高开、动能不足。
85
+ auctionVolRatioMin: 0.02,
86
+ // 竞价量能比满分线(%):达到即量能充沛
87
+ auctionVolRatioFull: 0.08,
88
+ },
89
+ // ---- P02 弱转强 / 超预期(前几日大涨 + 今日开盘符合或超预期)----
90
+ p2: {
91
+ // 昨日"强势"门槛:≥7% 视为大涨,≥9.5% 视为涨停
92
+ yesterdayStrong: 7.0,
93
+ yesterdayLimit: 9.5,
94
+ // 今日竞价高开"符合预期"区间(%)
95
+ expectLow: 3.0,
96
+ expectHigh: 7.0,
97
+ // "超预期"门槛(%):高开超过此值且昨日强 → 弱转强超预期
98
+ exceed: 7.0,
99
+ },
100
+ // ---- P03 趋势跟踪(最近走趋势)----
101
+ p3: {
102
+ maPeriods: [5, 10, 20, 60], // 均线周期
103
+ // 20 日新高 / 60 日新高判定用
104
+ highLookback: { d20: 20, d60: 60 },
105
+ // 斜率门槛:近 20 日收盘价线性回归斜率(%/日)超过此值视为上行趋势
106
+ slopeMinPerDay: 0.4,
107
+ // 斜率满分线(%/日)
108
+ slopeFullPerDay: 1.2,
109
+ },
110
+ },
111
+
112
+ // =========================================================================
113
+ // 6. 综合打分(总分 100,三项可解释)
114
+ // =========================================================================
115
+ score: {
116
+ weights: {
117
+ p1: 35, // 竞价强势
118
+ p2: 35, // 弱转强 / 超预期
119
+ p3: 30, // 趋势跟踪
120
+ },
121
+ // 候选入选总分下限(低于此不进报告)
122
+ minTotal: 45,
123
+ },
124
+
125
+ // =========================================================================
126
+ // 7. 排除项(不可买 / 噪声过大)
127
+ // =========================================================================
128
+ exclude: {
129
+ st: true,
130
+ suspended: true, // 停牌(成交量 0 或时间戳停在 09:00:00)
131
+ oneWordBoard: false, // 盘前不剔一字板:一字涨停本身是强信号,留待 P1 标记
132
+ newListing: true,
133
+ newListingDays: 20,
134
+ },
135
+
136
+ // =========================================================================
137
+ // 8. 分组与呈现
138
+ // =========================================================================
139
+ group: {
140
+ source: 'industry-map', // 复用 industry-map 缓存(build_industry_map.js 生成)
141
+ maxPerGroup: 3,
142
+ topN: 15,
143
+ unknownLabel: '未分类',
144
+ },
145
+
146
+ // =========================================================================
147
+ // 9. 落库与报告
148
+ // =========================================================================
149
+ store: {
150
+ dataDir: 'data',
151
+ reportDir: 'reports',
152
+ prefix: 'preopen', // 文件名前缀:preopen-YYYYMMDD.json / .html
153
+ keepRunHistory: 20,
154
+ },
155
+
156
+ // =========================================================================
157
+ // 10. 报告
158
+ // =========================================================================
159
+ report: {
160
+ title: '盘前选股',
161
+ titleEn: 'Pre-Open Screener',
162
+ maxRows: 60,
163
+ showDataGap: true,
164
+ },
165
+
166
+ // =========================================================================
167
+ // 11. 通知(复用 notify.js:微信 + 163 邮箱)
168
+ // =========================================================================
169
+ notify: {
170
+ enabled: true,
171
+ topInMessage: 8,
172
+ alertOnFailure: true,
173
+ },
174
+
175
+ // =========================================================================
176
+ // 12. 收盘回填(溯源增强,Q3)
177
+ // =========================================================================
178
+ backfill: {
179
+ // 回填运行窗口:收盘后 15:00 – 16:00(抓取当日收盘回填当日涨跌幅/是否涨停)
180
+ from: '15:00',
181
+ to: '16:00',
182
+ // 命中定义:开盘价买入,当日收盘涨幅 ≥ 该值视为"开盘信号有效"(%)
183
+ hitDayChgMin: 2.0,
184
+ },
185
+ };
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/preopen_backfill.js —— 收盘回填 + 命中率统计(溯源增强,Q3)
4
+ * ---------------------------------------------------------------------------
5
+ * 盘前快照只是"开盘信号",价值要收盘验证。本脚本在 15:00–16:00 运行:
6
+ * 1. 读取当日 preopen-YYYYMMDD.json 的 records
7
+ * 2. 抓取每只候选当日收盘(quote.price=收盘价,chgPct=当日涨幅)
8
+ * 3. 回填:当日涨跌幅 / 是否涨停 / 命中(开盘买入当日收益 ≥ hitDayChgMin)
9
+ * 4. 统计命中率,合并进归档(doc.close)
10
+ *
11
+ * 用法:
12
+ * node eod/preopen_backfill.js # 回填今日(受 15:00–16:00 窗口限制)
13
+ * node eod/preopen_backfill.js --date 2026-10-22
14
+ * node eod/preopen_backfill.js --force # 忽略时段
15
+ * node eod/preopen_backfill.js --no-push # 不推送归档
16
+ * ---------------------------------------------------------------------------
17
+ */
18
+ const fs = require('fs');
19
+ const { dayjs } = require('../time');
20
+ const cfg = require('./preopen.config');
21
+ const market = require('./lib/market');
22
+ const store = require('./lib/preopen_store');
23
+ const paths = require('../paths');
24
+ const { execFileSync } = require('child_process');
25
+
26
+ const argv = process.argv.slice(2);
27
+ const OPT = { date: (() => { const i = argv.indexOf('--date'); return i >= 0 ? argv[i + 1] : null; })(), force: argv.includes('--force'), noPush: argv.includes('--no-push') };
28
+ function log(m) { console.log(m); }
29
+
30
+ function boardLimitPct(code) {
31
+ if (/^sh68[89]/.test(code)) return 20;
32
+ if (/^sz30/.test(code)) return 20;
33
+ if (/^bj/.test(code)) return 30;
34
+ return 10;
35
+ }
36
+
37
+ async function run() {
38
+ const tradeDate = OPT.date || dayjs.tz().format('YYYY-MM-DD');
39
+ const now = dayjs.tz();
40
+ const hhmm = now.hour() * 100 + now.minute();
41
+ const from = parseInt(cfg.backfill.from.replace(':', ''), 10);
42
+ const to = parseInt(cfg.backfill.to.replace(':', ''), 10);
43
+ if (!OPT.force && (hhmm < from || hhmm > to)) {
44
+ log(`[结束] 未到收盘回填窗口(${cfg.backfill.from}–${cfg.backfill.to}),可用 --force 补跑`);
45
+ return { skipped: true };
46
+ }
47
+
48
+ const doc = store.loadRun(cfg, tradeDate);
49
+ if (!doc) { log(`[结束] 未找到盘前归档 ${tradeDate}`); return { skipped: true }; }
50
+ const codes = Object.keys(doc.records);
51
+ if (!codes.length) { log('[结束] 归档无候选'); return { skipped: true }; }
52
+ log(`[回填] 读取 ${codes.length} 只候选,抓当日收盘…`);
53
+
54
+ const quotes = (await market.fetchBatch(codes, cfg)).filter(Boolean);
55
+ const qmap = new Map(quotes.map((q) => [q.code, q]));
56
+
57
+ const closeMap = {};
58
+ for (const code of codes) {
59
+ const q = qmap.get(code);
60
+ if (!q || q.price == null) { closeMap[code] = { close: null, dayChgPct: null, limitUp: false, hit: false, note: '无收盘数据' }; continue; }
61
+ const dayChgPct = q.chgPct;
62
+ const limitPrice = q.prevClose > 0 ? Math.round(q.prevClose * (1 + boardLimitPct(code) / 100) * 100) / 100 : null;
63
+ const limitUp = limitPrice != null && q.price >= limitPrice - 0.005;
64
+ const hit = dayChgPct != null && dayChgPct >= cfg.backfill.hitDayChgMin;
65
+ closeMap[code] = { close: q.price, dayChgPct, limitUp, hit };
66
+ }
67
+
68
+ const summary = { hitDayChgMin: cfg.backfill.hitDayChgMin };
69
+ const close = store.mergeBackfill(cfg, tradeDate, closeMap, summary);
70
+ log(`[回填] 命中 ${close.hit}/${close.total}(开盘买入当日涨≥${cfg.backfill.hitDayChgMin}%)= ${close.hitRate}%`);
71
+
72
+ if (!OPT.noPush) {
73
+ try {
74
+ const file = store.dataFile(cfg, tradeDate);
75
+ const root = paths.PKG_ROOT;
76
+ const run = (a, af) => { try { return execFileSync('git', a, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }).toString(); } catch (e) { if (af) return ''; throw e; } };
77
+ run(['add', file]);
78
+ run(['-c', 'core.hooksPath=.git/cph', 'commit', '-m', `preopen: ${tradeDate} 收盘回填 命中率 ${close.hitRate}%`]);
79
+ const sha = run(['rev-parse', 'HEAD']).trim();
80
+ run(['-c', 'credential.helper=', '-c', 'credential.helper=wincred', 'push', 'origin', `${sha}:refs/heads/main`]);
81
+ log('[推送] 已提交并推送回填结果');
82
+ } catch (e) { log(`[推送] 失败(本地已回填):${e && e.message || e}`); }
83
+ }
84
+ return close;
85
+ }
86
+
87
+ run().catch((e) => { log(`[失败] ${e && e.stack || e}`); process.exitCode = 1; });