candidate-pool 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/lib/store_d1.js —— 尾盘选股落库到 Cloudflare D1(FR-10 的「发布」一侧)
4
+ * ---------------------------------------------------------------------------
5
+ * 设计:本地 JSON(eod/lib/store.js)仍是**权威归档**;本模块把同一份 doc 同步到 D1,
6
+ * 供网页端 /api/tail/* 读取。复用 db/d1client(与 backfill 同一套 HTTP API)。
7
+ *
8
+ * - 无 CF_* 凭据时静默跳过(本地开发 / 测试不污染远端)。
9
+ * - 自带 ensureTables:**自愈兜底**。DDL 不再复制到这里,统一从 db/ddl.js
10
+ * (= db/schema.sql,唯一真相源)取;先探一次 sqlite_master,缺表才整份重放。
11
+ * 因此即便没人跑过 `npm run db:migrate`,第一次带凭据的 EOD 运行也能把表建好。
12
+ * (2026-09-21 事故:此处原先硬编码了第二份 DDL,与 schema.sql 各写一份无人对齐,
13
+ * 加上本地/远程都没有可靠的应用路径 → 线上根本没有 tail_run / tail_pick。)
14
+ * - tail_pick 的 fill_json 用「空值保护」:传入空 {} 时不覆盖已回填的 fill,避免
15
+ * 同日「EOD 重跑」把 P5 收盘回填的结果清掉(实际时序上 EOD 早于 P5,本保护是双保险)。
16
+ * - **口径必须翻译**:本地脚本内部把「14:50 固定口径」叫 `cut`(eod/lib/trading.js 的
17
+ * `mode`,也是文件名分桶依据 —— daySuffix('cut') === ''),而 D1 / 网页 API 只有
18
+ * formal / observe(db/schema.sql、worker/src/types.ts)。落库前统一走 d1Mode(),
19
+ * 否则网页拿到 `cut` 会 400「mode 只能为 formal / observe」,记录页也会把它兜底显示成「观察」。
20
+ * (2026-09-21 事故:当天 14:50 的正式运行就是这样写进去的。)
21
+ */
22
+ const path = require('path');
23
+ const d1 = require('../../db/d1client');
24
+ const ddl = require('../../db/ddl');
25
+ const clients = require('../../db/clients');
26
+
27
+ /**
28
+ * 本地内部口径 → D1/API 口径。`cut` 是本地对「14:50 固定口径」的旧叫法,对外一律叫 `formal`。
29
+ * 未知口径直接抛错(宁可在同步这一步报出来,也不要往 D1 写第三种口径)。
30
+ */
31
+ const D1_MODE = { cut: 'formal', formal: 'formal', observe: 'observe' };
32
+ function d1Mode(mode) {
33
+ const m = D1_MODE[mode];
34
+ if (!m) throw new Error(`未知尾盘口径「${mode}」:D1 只接受 formal / observe`);
35
+ return m;
36
+ }
37
+
38
+ // 已确认「tail_run / tail_pick 就绪」的客户端集合。用 WeakSet 而不是一个布尔量:
39
+ // 尾盘现在可能同时写远程 D1 与本地 db/local.db(--both),两边的就绪状态必须各自记。
40
+ const _tablesReady = new WeakSet();
41
+ /**
42
+ * 建表兜底(自愈)。DDL 不再硬编码在这里 —— 统一从 db/ddl.js 取,
43
+ * 也就是 db/schema.sql(唯一真相源)。先花 1 次请求探 sqlite_master:
44
+ * 已就绪就直接返回,缺表才整份重放(schema.sql 全 IF NOT EXISTS,可重复执行)。
45
+ */
46
+ async function ensureTables(client) {
47
+ if (_tablesReady.has(client)) return;
48
+ try {
49
+ const rows = await client.query(
50
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('tail_run','tail_pick')",
51
+ );
52
+ if (Array.isArray(rows) && rows.length >= 2) { _tablesReady.add(client); return; }
53
+ } catch (_) {
54
+ // 探测失败(权限 / 网络抖动)时不要直接放弃,交给下面的整份重放兜底
55
+ }
56
+ await client.batch(ddl.loadStatements().map((sql) => ({ sql })));
57
+ _tablesReady.add(client);
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // SQL:**列清单是唯一真相源**。占位符由列清单派生、参数按列清单顺序装填,
62
+ // 三者(列 / `?` / params)因此结构上不可能错位。
63
+ //
64
+ // 2026-09-21 事故复盘:tail_pick 的列清单有 33 列,但 VALUES 是手写的 32 个 `?`,
65
+ // 每条候选插入都报 `7500: 32 values for 33 columns`;而 db/d1client 的 batch 是
66
+ // 「分片 Promise.all」**不是事务**(tail_run 在第一批就已提交),于是每轮同步都是
67
+ // 「运行记录写进去了、候选一条没有」—— 网页主表 / 分行业明细 / 口径对照全空,
68
+ // 记录页只剩一行 mode 还是错的元数据。手写占位符必须和手写列清单同步维护,
69
+ // 这里改成派生,从根上消除这类错位。
70
+ // ---------------------------------------------------------------------------
71
+ const RUN_COLS = [
72
+ 'trade_date', 'mode', 'cut_at', 'updated_at', 'run_at', 'candidate_count',
73
+ 'group_count', 'pre_pass_count', 'snapshot_count', 'runner', 'runs_json', 'stats_json',
74
+ ];
75
+ const PICK_COLS = [
76
+ 'trade_date', 'mode', 'code', 'name', 'sector', 'board', 'board_label', 'price',
77
+ 'prev_close', 'high', 'chg_pct', 'turnover', 'vol_ratio', 'vol_ratio_est', 'float_cap_yi',
78
+ 'avg_price', 'group_rank', 'best_in_group', 'group_size', 'total', 'sector_median_chg',
79
+ 'sector_rank', 'sector_total', 'tail_seg_pct', 'tail_up_ratio', 'tail_max_drawdown_pct',
80
+ 'tail_price_vs_avg_pct', 'tail_avg_at_cut', 'tail_p0', 'tail_p1', 'tail_bars',
81
+ 'score_json', 'fill_json',
82
+ ];
83
+
84
+ function ph(cols) { return cols.map(() => '?').join(','); }
85
+
86
+ /**
87
+ * 由列清单生成 UPSERT。冲突列不参与 SET,`except` 用于把需要特殊处理的列
88
+ * (如 fill_json 的空值保护)排出手写 SET 列表,再由 `extra` 追加自定义表达式。
89
+ */
90
+ function upsertSql(table, cols, pkCols, opts = {}) {
91
+ const skip = new Set([...pkCols, ...(opts.except || [])]);
92
+ const sets = cols.filter((c) => !skip.has(c)).map((c) => `${c}=excluded.${c}`);
93
+ if (opts.extra) sets.push(opts.extra);
94
+ return `INSERT INTO ${table} (${cols.join(',')})
95
+ VALUES (${ph(cols)})
96
+ ON CONFLICT(${pkCols.join(', ')}) DO UPDATE SET ${sets.join(',\n ')}`;
97
+ }
98
+
99
+ const RUN_Q = upsertSql('tail_run', RUN_COLS, ['trade_date', 'mode']);
100
+
101
+ // tail_pick 的 fill_json 用「空值保护」:传入空 {} 时不覆盖已回填的 fill,避免
102
+ // 同日「EOD 重跑」把 P5 收盘回填的结果清掉(实际时序上 EOD 早于 P5,本保护是双保险)。
103
+ const PICK_Q = upsertSql('tail_pick', PICK_COLS, ['trade_date', 'mode', 'code'], {
104
+ except: ['fill_json'],
105
+ extra: `fill_json = CASE WHEN excluded.fill_json IS NULL OR excluded.fill_json = '{}'
106
+ THEN tail_pick.fill_json ELSE excluded.fill_json END`,
107
+ });
108
+
109
+ function j(v) { return v == null ? null : JSON.stringify(v); }
110
+ function n(v) { return v == null ? null : v; }
111
+ function b1(v) { return v ? 1 : 0; }
112
+ /** 按列清单顺序把行对象摊平成绑定参数;缺列一律 null(绝不静默少一个参数)。 */
113
+ function toParams(cols, row) { return cols.map((c) => (row[c] === undefined ? null : row[c])); }
114
+
115
+ /**
116
+ * 写入目标解析:**本地运行默认两边都写,GitHub Actions 默认只写远程**。
117
+ *
118
+ * | 场景 | 远程 D1 | 本地 db/local.db |
119
+ * |-------------------------|---------|------------------|
120
+ * | 本机跑(默认) | 写 | 写 |
121
+ * | GitHub Actions(默认) | 写 | 不写 |
122
+ * | 显式 `--local` | 不写 | 写 |
123
+ * | 显式 `--both` | 写 | 写 |
124
+ *
125
+ * 为什么本地默认要补写本地库:尾盘原先**只**写远程 D1,于是 db/local.db 的
126
+ * tail_run / tail_pick 永远是 0 行(`db/clients.js` 的注释里记着同一个毛病:
127
+ * 「本机日常任务与 CI 都走远程,于是 db/local.db 永远停在旧数据上」)。
128
+ * 本机跑一次就顺手把本地库补齐,复盘 / 离线查询才用得上。
129
+ * GitHub runner 上没有有意义的 db/local.db(每次都是全新容器),所以只写远程。
130
+ */
131
+ function targetArgv(argv) {
132
+ if (argv.includes('--local') || argv.includes('--both')) return argv;
133
+ return process.env.GITHUB_ACTIONS ? argv : [...argv, '--both'];
134
+ }
135
+
136
+ /** 目标的中文名,用于日志(远程显示 D1,本地带上文件名)。 */
137
+ function targetLabel(t) {
138
+ return t.name === 'remote' ? 'D1' : `本地 ${path.basename(t.file || 'local.db')}`;
139
+ }
140
+
141
+ /**
142
+ * 把一次 EOD 运行结果同步到**数据源**(远程 D1 / 本地 db/local.db)。
143
+ * 写入目标见 targetArgv()(本地默认两边、CI 只远程;`--local` / `--both` 可覆盖)。
144
+ *
145
+ * @param {object} doc 与 store.saveRun 返回 doc 同构:{ tradeDate, mode, cutAt, updatedAt, runs, stats, records }
146
+ * @param {{argv?: string[]}} [opts] argv 默认 process.argv
147
+ * @returns {{skipped?:boolean, reason?:string, run?:number, picks?:number, error?:string,
148
+ * results?: Array<{name:string, ok:boolean, file?:string, error?:string}>, notes?: string[]}}
149
+ */
150
+ async function syncTailRun(doc, { argv = process.argv } = {}) {
151
+ let built;
152
+ try { built = buildStatements(doc); } catch (e) { return { error: e.message || String(e) }; }
153
+
154
+ const { targets, notes } = clients.resolveTargets(targetArgv(argv));
155
+ if (!targets.length) {
156
+ return { skipped: true, reason: notes.join(';') || '没有可写入的目标' };
157
+ }
158
+
159
+ const results = [];
160
+ for (const t of targets) {
161
+ try {
162
+ await ensureTables(t.client);
163
+ await t.client.batch([built.runStmt, ...built.pickStmts]);
164
+ results.push({ name: t.name, ok: true, file: t.file });
165
+ } catch (e) {
166
+ results.push({ name: t.name, ok: false, file: t.file, error: e.message || String(e) });
167
+ }
168
+ }
169
+ // 一端成功就算成功(另一端失败只影响那一端;本地 JSON 归档始终在)
170
+ if (!results.some((r) => r.ok)) {
171
+ return { error: results.map((r) => `${targetLabel(r)}: ${r.error || '未知错误'}`).join(';'), results };
172
+ }
173
+ return { run: 1, picks: built.pickStmts.length, results, notes };
174
+ }
175
+
176
+ /**
177
+ * 由存档 doc 构造要发给 D1 的语句(**纯函数,不联网**)。
178
+ * 抽出来是为了让自检能在离线状态下断言「列数 = 占位符数 = params 数」——
179
+ * 这正是 2026-09-21 那次「有运行记录、没有候选」事故的直接原因。
180
+ * @param {object} doc 同 store.saveRun 的返回 doc
181
+ * @returns {{runStmt: {sql:string, params:any[]}, pickStmts: {sql:string, params:any[]}[]}}
182
+ */
183
+ function buildStatements(doc) {
184
+ const lastRun = Array.isArray(doc.runs) && doc.runs.length ? doc.runs[doc.runs.length - 1] : null;
185
+ const runAt = (lastRun && lastRun.at) || doc.updatedAt || null;
186
+ const mode = d1Mode(doc.mode);
187
+ const runRow = {
188
+ trade_date: doc.tradeDate, mode, cut_at: doc.cutAt ?? null, updated_at: doc.updatedAt ?? null,
189
+ run_at: runAt,
190
+ candidate_count: Number(doc.stats?.candidateCount ?? 0),
191
+ group_count: Number(doc.stats?.groupCount ?? 0),
192
+ pre_pass_count: Number(doc.stats?.prePassCount ?? 0),
193
+ snapshot_count: Number(doc.stats?.snapshotCount ?? 0),
194
+ runner: (lastRun && lastRun.runner) || 'local',
195
+ runs_json: j(doc.runs), stats_json: j(doc.stats),
196
+ };
197
+ const runStmt = { sql: RUN_Q, params: toParams(RUN_COLS, runRow) };
198
+
199
+ const records = doc.records || {};
200
+ const pickStmts = Object.values(records).map((r) => {
201
+ const row = {
202
+ trade_date: doc.tradeDate, mode, code: r.code, name: n(r.name), sector: n(r.sector),
203
+ board: n(r.board), board_label: n(r.boardLabel), price: n(r.price), prev_close: n(r.prevClose),
204
+ high: n(r.high), chg_pct: n(r.chgPct), turnover: n(r.turnover), vol_ratio: n(r.volRatio),
205
+ vol_ratio_est: n(r.volRatioEst), float_cap_yi: n(r.floatCapYi), avg_price: n(r.avgPrice),
206
+ group_rank: n(r.groupRank), best_in_group: b1(r.bestInGroup), group_size: n(r.groupSize),
207
+ total: n(r.total), sector_median_chg: n(r.sectorMedianChg), sector_rank: n(r.sectorRank),
208
+ sector_total: n(r.sectorTotal), tail_seg_pct: n(r.tail?.segPct),
209
+ tail_up_ratio: n(r.tail?.upRatio), tail_max_drawdown_pct: n(r.tail?.maxDrawdownPct),
210
+ tail_price_vs_avg_pct: n(r.tail?.priceVsAvgPct), tail_avg_at_cut: n(r.tail?.avgAtCut),
211
+ tail_p0: n(r.tail?.p0), tail_p1: n(r.tail?.p1), tail_bars: j(r.tail?.bars),
212
+ score_json: j(r.score), fill_json: j(r.fill),
213
+ };
214
+ return { sql: PICK_Q, params: toParams(PICK_COLS, row) };
215
+ });
216
+ return { runStmt, pickStmts };
217
+ }
218
+
219
+ /**
220
+ * 收盘回填(P5):把已算好的 N1~N10 表现写进某交易日的 tail_pick.fill_json。
221
+ * 与 store.applyFill 不同,这里写数据库(远程 D1 / 本地 db/local.db,目标同 syncTailRun),
222
+ * 本地 JSON 归档由 P5 脚本负责。
223
+ * @param {string} tradeDate
224
+ * @param {string} mode
225
+ * @param {Record<string, object>} patch code -> { close, n1..n10, filledAt }
226
+ * @param {{argv?: string[]}} [opts]
227
+ */
228
+ async function applyTailFill(tradeDate, mode, patch, { argv = process.argv } = {}) {
229
+ if (!Object.keys(patch || {}).length) return { updated: 0 };
230
+ let filled;
231
+ try { filled = d1Mode(mode); } catch (e) { return { error: e.message || String(e) }; }
232
+
233
+ const { targets, notes } = clients.resolveTargets(targetArgv(argv));
234
+ if (!targets.length) return { skipped: true, reason: notes.join(';') || '没有可写入的目标' };
235
+
236
+ const stmts = Object.entries(patch).map(([code, fill]) => ({
237
+ sql: `UPDATE tail_pick SET fill_json = ? WHERE trade_date = ? AND mode = ? AND code = ?`,
238
+ params: [j(fill), tradeDate, filled, code],
239
+ }));
240
+
241
+ const results = [];
242
+ for (const t of targets) {
243
+ try {
244
+ await ensureTables(t.client);
245
+ await t.client.batch(stmts);
246
+ results.push({ name: t.name, ok: true, file: t.file, updated: stmts.length });
247
+ } catch (e) {
248
+ results.push({ name: t.name, ok: false, file: t.file, error: e.message || String(e) });
249
+ }
250
+ }
251
+ if (!results.some((r) => r.ok)) {
252
+ return { error: results.map((r) => `${targetLabel(r)}: ${r.error || '未知错误'}`).join(';'), results };
253
+ }
254
+ return { updated: stmts.length, results, notes };
255
+ }
256
+
257
+ /**
258
+ * 清理历史遗留口径行:本地旧叫法 `cut` 曾被直接写进 D1(2026-09-21 的 14:50 正式运行),
259
+ * 而 D1 / 网页 API 只认 formal / observe。补发前先删掉,否则同一天会出现
260
+ * 「cut + formal」两条同义运行,记录页里那一天会重复一行。
261
+ * 与 syncTailRun 同样作用于所有写入目标。
262
+ * @param {{argv?: string[]}} [opts]
263
+ * @returns {{results: Array<{name:string, file?:string, deleted?:Record<string,number|string>, error?:string}>, notes?:string[]}}
264
+ */
265
+ async function cleanupLegacyModes({ argv = process.argv } = {}) {
266
+ const { targets, notes } = clients.resolveTargets(targetArgv(argv));
267
+ if (!targets.length) return { skipped: true, reason: notes.join(';') || '没有可写入的目标' };
268
+
269
+ const results = [];
270
+ for (const t of targets) {
271
+ const deleted = {};
272
+ try {
273
+ for (const tbl of ['tail_run', 'tail_pick']) {
274
+ const r = await t.client.exec(`DELETE FROM ${tbl} WHERE mode NOT IN ('formal','observe')`);
275
+ const n = r && r.meta && r.meta.changes;
276
+ // 远程 D1 会回报 meta.changes;本地 node:sqlite 的 exec 不回传,退回 'ok'
277
+ deleted[tbl] = n == null ? 'ok' : n;
278
+ }
279
+ results.push({ name: t.name, file: t.file, deleted });
280
+ } catch (e) {
281
+ results.push({ name: t.name, file: t.file, error: e.message || String(e) });
282
+ }
283
+ }
284
+ return { results, notes };
285
+ }
286
+
287
+ // d1Mode / SQL / COLS 仅为自测(eod/lib/trading.selftest.js 第 9 节)导出,
288
+ // 业务代码不要用它们拼 SQL —— 走 syncTailRun / applyTailFill。
289
+ module.exports = {
290
+ syncTailRun, applyTailFill, ensureTables, d1Mode, cleanupLegacyModes, buildStatements,
291
+ targetArgv, targetLabel,
292
+ SQL: { RUN_Q, PICK_Q }, COLS: { RUN_COLS, PICK_COLS },
293
+ };
@@ -0,0 +1,252 @@
1
+ 'use strict';
2
+ /*
3
+ * eod/lib/trading.js —— 交易日 / 交易时段判定(不依赖任何外部日历接口)
4
+ * ---------------------------------------------------------------------------
5
+ * 为什么不用节假日日历:
6
+ * 维护一张 A 股节假日表意味着每年都要更新、且调休(周末补班)极易出错。
7
+ * 而行情本身带时间戳(腾讯报价第 [30] 字段 = YYYYMMDDHHmmss):
8
+ * **今天是交易日 ⟺ 行情时间戳的日期 == 今天**。停牌个股不推进,所以取全市场众数而非个例。
9
+ * 这一条同时覆盖了「周末」「法定节假日」「临时休市」三种情况,零维护。
10
+ *
11
+ * 三段式判定:
12
+ * 1. 本地时钟(北京时间)先做粗筛:周末 / 早于 earliest / 晚于收盘宽限期 → 直接跳过
13
+ * 2. 行情时间戳做终筛:日期不是今天 → 今天没开盘,跳过
14
+ * 3. 根据当前时点决定「数据截止时间」:
15
+ * now >= cutAt(14:50) → mode='cut' 按 14:50 截断(固定口径,同日多次运行结果一致)
16
+ * observeFrom <= now < cutAt → mode='observe' 取到最新点位,但报告标注「观察模式」
17
+ * now < observeFrom + intraday=true → mode='intraday'(**显式** --intraday 才走)
18
+ * 取到当前已成交的最后一分钟,段 = 最近 segMinutes 分钟
19
+ *
20
+ * 关于「时段守卫」:默认(不传 --intraday)在 14:30 之前一律 ok:false,这是**有意保留**的——
21
+ * 定时任务在用户机器上按点触发,若允许早跑就会产出 0 候选的空报告并推送。想看盘中近似口径,
22
+ * 必须显式加 --intraday(见 eod/tail.config.js 的 session.intraday 说明)。
23
+ * ---------------------------------------------------------------------------
24
+ */
25
+
26
+ const { dayjs } = require('../../time');
27
+
28
+ const TZ = 'Asia/Shanghai';
29
+
30
+ /** 当前北京时间(dayjs 对象) */
31
+ function nowBjt() { return dayjs.tz(); }
32
+
33
+ /** 'HH:mm' -> 当天分钟数 */
34
+ function hhmmToMin(hhmm) {
35
+ const m = String(hhmm).match(/^(\d{1,2}):(\d{2})$/);
36
+ if (!m) throw new Error(`时间格式应为 HH:mm,收到 ${hhmm}`);
37
+ return (+m[1]) * 60 + (+m[2]);
38
+ }
39
+
40
+ /** 某个 dayjs 时刻的当日分钟数 */
41
+ function minOfDay(d) { return d.hour() * 60 + d.minute(); }
42
+
43
+ /** 分钟数 -> 'HH:MM' */
44
+ function fmtMin(m) {
45
+ const x = Math.max(0, Math.min(24 * 60 - 1, Math.round(m)));
46
+ return `${String(Math.floor(x / 60)).padStart(2, '0')}:${String(x % 60).padStart(2, '0')}`;
47
+ }
48
+
49
+ /**
50
+ * 把分钟数夹到「最后一根已成交的分时」。
51
+ * A 股分时是 09:30–11:30 / 13:00–15:00,**午休 11:30–13:00 没有任何 K 线**,
52
+ * 所以 12:xx 时若拿当前时钟当 cut,取到的其实是 11:30 那根。
53
+ * 这里显式夹一下,让报告里显示的「口径 HH:MM」与实际取数的时点一致(不误导)。
54
+ */
55
+ function clampToTradedMinute(M) {
56
+ const open = 9 * 60 + 30;
57
+ const noon = 11 * 60 + 30;
58
+ const noonEnd = 13 * 60;
59
+ const close = 15 * 60;
60
+ if (M <= open) return open;
61
+ if (M <= noon) return M;
62
+ if (M < noonEnd) return noon;
63
+ if (M <= close) return M;
64
+ return close;
65
+ }
66
+
67
+ /**
68
+ * 从 cut 往前退 n 个「**已成交**分钟」,返回起点 'HHMM'。
69
+ * 与 clampToTradedMinute 是一对:那个夹的是窗口**终点**,这个退的是窗口**起点**。
70
+ *
71
+ * 为什么不能用 `cut - n` 直接算:A 股午休 11:30–13:00 **没有任何 K 线**,
72
+ * 13:10 的「最近 20 分钟」按墙钟算是 12:50 —— 落在午休里,取不到起点 K 线,
73
+ * tailMetrics 便返回 null → **整只票被记为 data_gap 剔除**。
74
+ * 实测(合成全天分时):13:10 时 segFrom=1250,起点缺失、回退到 1130 滞后 80 分钟
75
+ * (远超 market.js 的 SEG_START_LAG_MIN=3)→ 下午开盘后 20 分钟内跑盘中模式**静默 0 候选**。
76
+ * 正确口径是 11:20 → 11:30 + 13:00 → 13:10(正好 20 个已成交分钟),
77
+ * 这样窗口长度恒为 n,tailSegMinPct=0.5 / tailMomentumFullPct=2.0 的标定才始终成立。
78
+ *
79
+ * 上午用尽仍不足 n 时停在开盘 09:30(等价于原来的 Math.max(earliest, cut - n))。
80
+ */
81
+ function backTradedMinutes(cut, n) {
82
+ const open = 9 * 60 + 30;
83
+ const noon = 11 * 60 + 30;
84
+ const noonEnd = 13 * 60;
85
+ let m = clampToTradedMinute(cut);
86
+ let remain = Math.max(0, Math.round(n));
87
+ while (remain > 0) {
88
+ if (m <= noon) { // 上午段
89
+ const step = Math.min(remain, m - open);
90
+ if (step <= 0) return open; // 已退到开盘
91
+ m -= step; remain -= step;
92
+ } else if (m < noonEnd) { // 午休(clamp 后理论上不会进来)
93
+ m = noon;
94
+ } else { // 下午段
95
+ const step = Math.min(remain, m - noonEnd);
96
+ m -= step; remain -= step;
97
+ if (remain > 0) m = noon; // 下午退尽 → 跨过午休,从 11:30 继续
98
+ }
99
+ }
100
+ return Math.max(open, m);
101
+ }
102
+
103
+ /** 'YYYYMMDDHHmmss' | 'YYYYMMDD' -> 'YYYY-MM-DD';无法识别返回 null */
104
+ function stampToDate(stamp) {
105
+ const s = String(stamp || '');
106
+ if (!/^\d{8}/.test(s)) return null;
107
+ return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
108
+ }
109
+
110
+ /** 已开盘交易分钟数(A 股:上午 120 + 下午 120 = 240)。用于把即时量比折算成「收盘预估量比」。 */
111
+ function elapsedTradingMinutes(minutesOfDay) {
112
+ const M = minutesOfDay;
113
+ if (M <= 9 * 60 + 30) return 0;
114
+ if (M <= 11 * 60 + 30) return M - (9 * 60 + 30);
115
+ if (M <= 13 * 60) return 120;
116
+ if (M <= 15 * 60) return 120 + (M - 13 * 60);
117
+ return 240;
118
+ }
119
+
120
+ const TOTAL_TRADING_MINUTES = 240;
121
+
122
+ /**
123
+ * 判定当前是否可运行,以及数据口径。
124
+ * @param {object} p
125
+ * - cfg 配置(cfg.session / cfg.market)
126
+ * - now dayjs 时刻(默认当前北京时间);可注入便于测试
127
+ * - marketDate 行情时间戳推导出的交易日 'YYYY-MM-DD'(可为 null:尚未抓行情)
128
+ * - force 忽略所有时段限制(用于 --force 补跑 / 离线回放)
129
+ * - intraday 盘中模式(--intraday):在尾盘观察时点之前也允许运行,
130
+ * 尾盘段改用「cut 往前 segMinutes 分钟」的滚动窗口。
131
+ * **默认 false** —— 不传则一切照旧(时段守卫原样保留)。
132
+ * - segMinutes 覆盖盘中模式的滚动窗口长度(分钟)。不传则用 cfg.session.intraday.segMinutes。
133
+ * @returns {{ok:boolean, reason:string, mode:'cut'|'observe'|'intraday'|'invalid',
134
+ * tradeDate:string|null, cutTime:string, cutMinutes:number, segFrom:string,
135
+ * elapsed:number, estimateFullDay:boolean}}
136
+ * segFrom:尾盘段起点的 'HHMM'(正式/观察 = 固定 14:30;盘中 = 滚动窗口)
137
+ */
138
+ function sessionState({ cfg, now = null, marketDate = null, force = false, intraday = false, segMinutes = null }) {
139
+ const d = now || nowBjt();
140
+ const s = cfg.session;
141
+ const today = d.format('YYYY-MM-DD');
142
+ const day = d.day(); // 0=周日 6=周六
143
+ const M = minOfDay(d);
144
+
145
+ const earliest = hhmmToMin(s.earliest);
146
+ const observeFrom = hhmmToMin(s.observeFrom);
147
+ const cutAt = hhmmToMin(s.cutAt);
148
+ const close = hhmmToMin(s.close);
149
+ const graceEnd = close + (s.graceMinutesAfterClose || 0);
150
+
151
+ // 尾盘段的**默认**起点:正式 / 观察口径都固定从 observeFrom(14:30)起算。
152
+ // 盘中模式会用滚动窗口覆盖它 —— 故三种模式统一在返回值里带 segFrom,
153
+ // 调用方只读 ss.segFrom,不要再各自去 cfg.session.observeFrom 里取(否则盘中模式会取错)。
154
+ const segFromDefault = s.observeFrom.replace(':', '');
155
+
156
+ const base = {
157
+ mode: 'invalid', cutTime: s.cutAt, cutMinutes: cutAt, segFrom: segFromDefault,
158
+ elapsed: elapsedTradingMinutes(Math.min(M, close)),
159
+ estimateFullDay: M >= close,
160
+ };
161
+
162
+ if (force) {
163
+ // 强制模式:口径按已收盘处理(分时数据已完整),交易日取行情日期或今天
164
+ return {
165
+ ...base,
166
+ ok: true,
167
+ mode: 'cut',
168
+ reason: '强制模式(忽略时段与交易日限制)',
169
+ tradeDate: marketDate || today,
170
+ elapsed: TOTAL_TRADING_MINUTES,
171
+ estimateFullDay: true,
172
+ };
173
+ }
174
+
175
+ if (day === 0 || day === 6) {
176
+ return { ...base, ok: false, reason: `今天是${day === 0 ? '周日' : '周六'},非交易日` , tradeDate: null };
177
+ }
178
+ if (M < earliest) {
179
+ return { ...base, ok: false, reason: `当前 ${d.format('HH:mm')} 早于开盘可运行时点 ${s.earliest}`, tradeDate: null };
180
+ }
181
+ if (M > graceEnd) {
182
+ return { ...base, ok: false, reason: `当前 ${d.format('HH:mm')} 已超出收盘后补跑宽限期(至 ${String(Math.floor(graceEnd / 60)).padStart(2, '0')}:${String(graceEnd % 60).padStart(2, '0')})`, tradeDate: null };
183
+ }
184
+ // 行情终筛:今天到底有没有开盘
185
+ if (marketDate && marketDate !== today) {
186
+ return { ...base, ok: false, reason: `行情日期为 ${marketDate},今日(${today})未开盘(休市/停牌)`, tradeDate: marketDate };
187
+ }
188
+
189
+ if (M < observeFrom) {
190
+ // ---- 盘中模式(--intraday):尾盘时点之前也允许跑 ----
191
+ // 段改成「cut 往前 segMinutes 分钟」的滚动窗口,因为此时分时里根本没有 14:30 那一根
192
+ // (tailMetrics 要求起点 K 线存在,否则整只票会被判为 data_gap → 盘中跑必然 0 候选)。
193
+ // **默认关闭**:不传 --intraday 时下面那条 ok:false 就是时段守卫,防止定时任务/上午误触发空跑。
194
+ const ic = s.intraday || {};
195
+ if (intraday && ic.enabled !== false) {
196
+ const segMin = Math.max(1, Math.round(segMinutes || ic.segMinutes || 20));
197
+ const cut = clampToTradedMinute(M);
198
+ // 起点按「已成交分钟」往回数(跨过午休),**不要**写成 Math.max(earliest, cut - segMin):
199
+ // 墙钟减法会让下午刚开盘时的起点落进午休区,取不到起点 K 线 → 整只票 data_gap。
200
+ const from = backTradedMinutes(cut, segMin);
201
+ return {
202
+ ...base, ok: true, mode: 'intraday', tradeDate: today,
203
+ reason: `盘中模式:未到 ${s.observeFrom},按当前时点 ${fmtMin(cut)} 取数,`
204
+ + `尾盘段用最近 ${segMin} 个成交分钟(${fmtMin(from)}→${fmtMin(cut)})近似,口径未固定`,
205
+ cutTime: fmtMin(cut), cutMinutes: cut,
206
+ segFrom: fmtMin(from).replace(':', ''),
207
+ segMinutes: segMin,
208
+ elapsed: elapsedTradingMinutes(cut),
209
+ estimateFullDay: false,
210
+ };
211
+ }
212
+ const mins = observeFrom - M;
213
+ return {
214
+ ...base, ok: false, tradeDate: today,
215
+ reason: `当前 ${d.format('HH:mm')} 未到尾盘观察时点 ${s.observeFrom}(还差 ${mins} 分钟)`
216
+ + `;如需盘中近似口径,加 --intraday`,
217
+ };
218
+ }
219
+
220
+ if (M >= cutAt) {
221
+ return {
222
+ ...base, ok: true, mode: 'cut', tradeDate: today,
223
+ reason: `标准口径:按 ${s.cutAt} 截断取数`,
224
+ cutTime: s.cutAt, cutMinutes: cutAt,
225
+ elapsed: elapsedTradingMinutes(Math.min(M, close)),
226
+ estimateFullDay: M >= close,
227
+ };
228
+ }
229
+
230
+ // 14:30 ≤ now < 14:50 —— 观察模式
231
+ return {
232
+ ...base, ok: true, mode: 'observe', tradeDate: today,
233
+ reason: `观察模式:未到 ${s.cutAt},按当前时点 ${d.format('HH:mm')} 取数(口径会变,结果仅供参考)`,
234
+ cutTime: d.format('HH:mm'), cutMinutes: M,
235
+ elapsed: elapsedTradingMinutes(M),
236
+ estimateFullDay: false,
237
+ };
238
+ }
239
+
240
+ /** 收盘预估量比:即时量比 × (240 / 已交易分钟)。量能非匀速,故仅作参考并需在报告中标注「估算」。 */
241
+ function estimateFullDayVolRatio(volRatio, elapsed) {
242
+ if (!volRatio || !elapsed || elapsed <= 0) return null;
243
+ if (elapsed >= TOTAL_TRADING_MINUTES) return volRatio;
244
+ return volRatio * (TOTAL_TRADING_MINUTES / elapsed);
245
+ }
246
+
247
+ module.exports = {
248
+ nowBjt, hhmmToMin, minOfDay, fmtMin, clampToTradedMinute, backTradedMinutes,
249
+ stampToDate,
250
+ elapsedTradingMinutes, sessionState, estimateFullDayVolRatio,
251
+ TOTAL_TRADING_MINUTES, TZ,
252
+ };