candidate-pool 2.2.0 → 3.0.1
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 +85 -0
- package/eod/data/preopen-2026-10-22.json +1818 -0
- package/eod/lib/market.js +50 -0
- package/eod/lib/preopen_report.js +418 -0
- package/eod/lib/preopen_rules.js +190 -0
- package/eod/lib/preopen_store.js +105 -0
- package/eod/lib/report.js +23 -15
- package/eod/lib/screen.js +13 -25
- package/eod/preopen.config.js +185 -0
- package/eod/preopen_backfill.js +87 -0
- package/eod/preopen_screener.js +375 -0
- package/eod/tail.config.js +46 -19
- package/gen_candidates.js +5 -1
- package/package.json +5 -1
|
@@ -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; });
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/preopen_screener.js —— 盘前选股入口(Pre-Open Screener 编排层)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 流程:
|
|
6
|
+
* 1. 时段守卫(sessionState):非交易日 / 未到 9:20 / 已过 9:35 → 跳过
|
|
7
|
+
* 2. 抓竞价快照:观察池(fetchBatch) 或 全市场(fullMarketSnapshot)
|
|
8
|
+
* 3. 基线:前一日 K 线算 昨涨 + 趋势(观察池实时算;全市场用隔夜缓存)
|
|
9
|
+
* 4. 应用 P1/P2/P3 → 排除项 → 综合打分 → 分组
|
|
10
|
+
* 5. 落库(JSON) → HTML 报告 → 通知 → git 推送溯源
|
|
11
|
+
*
|
|
12
|
+
* 用法:
|
|
13
|
+
* node eod/preopen_screener.js # 观察池,正常(受时段限制)
|
|
14
|
+
* node eod/preopen_screener.js --universe # 全市场盲扫(需 baseline 缓存)
|
|
15
|
+
* node eod/preopen_screener.js --scope universe # 同上
|
|
16
|
+
* node eod/preopen_screener.js --baseline # 仅预计算基线缓存(供全市场次日用)
|
|
17
|
+
* node eod/preopen_screener.js --force # 忽略时段,按当前快照取数
|
|
18
|
+
* node eod/preopen_screener.js --now "2026-10-22 09:25" # 指定时刻(测试/补跑)
|
|
19
|
+
* node eod/preopen_screener.js --no-notify # 不推送通知
|
|
20
|
+
* node eod/preopen_screener.js --no-push # 不 git 推送(仅落本地)
|
|
21
|
+
* node eod/preopen_screener.js --json # stdout 机器摘要
|
|
22
|
+
* node eod/preopen_screener.js --paths # 打印落点
|
|
23
|
+
*
|
|
24
|
+
* 时段守卫:9:20–9:35 之外静默跳过(避免定时任务误触 / 上午空跑)。
|
|
25
|
+
* 开盘价(open)在 9:25 撮合后固定,9:25–9:35 抓到的 open 一致 → 调度有 5 分钟宽限。
|
|
26
|
+
* ---------------------------------------------------------------------------
|
|
27
|
+
*/
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const { execFileSync } = require('child_process');
|
|
31
|
+
|
|
32
|
+
const cfg = require('./preopen.config');
|
|
33
|
+
const { dayjs } = require('../time');
|
|
34
|
+
const { lastClosedTradingDay } = require('../rules');
|
|
35
|
+
const market = require('./lib/market');
|
|
36
|
+
const PR = require('./lib/preopen_rules');
|
|
37
|
+
const store = require('./lib/preopen_store');
|
|
38
|
+
const { buildHTML } = require('./lib/preopen_report');
|
|
39
|
+
const { loadIndustryMap, listedOf, sectorOf, UNKNOWN } = require('./lib/sector');
|
|
40
|
+
const { mapLimit } = require('./lib/http');
|
|
41
|
+
const { notify } = require('../notify');
|
|
42
|
+
const paths = require('../paths');
|
|
43
|
+
const scaffold = require('../scaffold');
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
function argOf(f) { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; }
|
|
47
|
+
const OPT = {
|
|
48
|
+
universe: argv.includes('--universe') || argOf('--scope') === 'universe',
|
|
49
|
+
baseline: argv.includes('--baseline'),
|
|
50
|
+
force: argv.includes('--force'),
|
|
51
|
+
now: argOf('--now'),
|
|
52
|
+
noNotify: argv.includes('--no-notify'),
|
|
53
|
+
noPush: argv.includes('--no-push'),
|
|
54
|
+
json: argv.includes('--json'),
|
|
55
|
+
paths: argv.includes('--paths'),
|
|
56
|
+
init: argv.includes('--init'),
|
|
57
|
+
stamp: argv.includes('--stamp'),
|
|
58
|
+
};
|
|
59
|
+
const RUNNER = process.env.GITHUB_ACTIONS ? 'github' : 'local';
|
|
60
|
+
function log(m) { if (!OPT.json) console.log(m); }
|
|
61
|
+
|
|
62
|
+
function fail(msg, err) {
|
|
63
|
+
log(`[失败] ${msg}` + (err ? `\n${err && err.stack || err}` : ''));
|
|
64
|
+
if (cfg.notify.enabled && cfg.notify.alertOnFailure && !OPT.noNotify) {
|
|
65
|
+
notify({ title: `盘前选股失败 · ${dayjs.tz().format('MM-DD HH:mm')}`, content: `${msg}\n\n${err && err.message || err || ''}` })
|
|
66
|
+
.then((res) => { for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败'}`); })
|
|
67
|
+
.catch(() => {});
|
|
68
|
+
}
|
|
69
|
+
process.exitCode = 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- 时段守卫 ----------
|
|
73
|
+
function sessionState({ now, marketDate, force }) {
|
|
74
|
+
const from = parseInt(cfg.session.openFrom.replace(':', ''), 10);
|
|
75
|
+
const to = parseInt(cfg.session.openTo.replace(':', ''), 10);
|
|
76
|
+
const hhmm = now.hour() * 100 + now.minute();
|
|
77
|
+
const today = now.format('YYYY-MM-DD');
|
|
78
|
+
if (force) return { ok: true, reason: '--force 忽略时段', tradeDate: marketDate || today };
|
|
79
|
+
if (hhmm < from) return { ok: false, reason: `未到盘前窗口(${cfg.session.openFrom} 起)` };
|
|
80
|
+
if (hhmm > to) return { ok: false, reason: `已过盘前窗口(${cfg.session.openTo} 止,可用 --force 补跑)` };
|
|
81
|
+
if (marketDate && marketDate !== today) {
|
|
82
|
+
return { ok: false, reason: `行情日期 ${marketDate} ≠ 今日,非交易日,跳过` };
|
|
83
|
+
}
|
|
84
|
+
return { ok: true, reason: '盘前窗口内', tradeDate: marketDate || today };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- 观察池加载 ----------
|
|
88
|
+
function readCandidates() {
|
|
89
|
+
const f = paths.candidatesFile();
|
|
90
|
+
if (!fs.existsSync(f)) throw new Error('candidates.json 不存在,无法加载观察池');
|
|
91
|
+
const c = JSON.parse(fs.readFileSync(f, 'utf8'));
|
|
92
|
+
const list = (c.stocks || []).map((s) => ({ code: String(s.code), name: String(s.name || s.code), sector: String(s.sector || '未分类') }));
|
|
93
|
+
if (!list.length) throw new Error('candidates.json 的 stocks 为空');
|
|
94
|
+
return list;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------- 基线(前一日 K 线指标)----------
|
|
98
|
+
async function getBaseline(codes, anchor, scope) {
|
|
99
|
+
if (scope === 'universe') {
|
|
100
|
+
const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
|
|
101
|
+
if (fs.existsSync(cf)) {
|
|
102
|
+
log(`[基线] 加载缓存 ${path.basename(cf)}`);
|
|
103
|
+
const m = new Map();
|
|
104
|
+
for (const [code, b] of Object.entries(JSON.parse(fs.readFileSync(cf, 'utf8')))) m.set(code, b);
|
|
105
|
+
return m;
|
|
106
|
+
}
|
|
107
|
+
if (!cfg.baseline.computeIfMissing) { log('[基线] 全市场无缓存且禁用实时计算 → 跳过趋势(P3)'); return new Map(); }
|
|
108
|
+
log('[基线] 全市场缓存缺失,实时计算(较慢,约数分钟)…');
|
|
109
|
+
}
|
|
110
|
+
const m = new Map();
|
|
111
|
+
const res = await mapLimit(codes, cfg.market.concurrency, async (code) => {
|
|
112
|
+
try {
|
|
113
|
+
const kl = await market.fetchKline(code, anchor);
|
|
114
|
+
return [code, PR.calcBaseline(kl, code, cfg)];
|
|
115
|
+
} catch (e) { return [code, { error: String((e && e.message) || e) }]; }
|
|
116
|
+
});
|
|
117
|
+
let okN = 0, errN = 0;
|
|
118
|
+
for (const [code, b] of res) {
|
|
119
|
+
if (b && !b.error) { m.set(code, b); okN++; } else errN++;
|
|
120
|
+
}
|
|
121
|
+
log(`[基线] K线 ${okN} 只成功 / ${errN} 只失败`);
|
|
122
|
+
if (scope === 'universe' && m.size) {
|
|
123
|
+
const out = {}; for (const [code, b] of m) out[code] = b;
|
|
124
|
+
fs.mkdirSync(path.dirname(path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor))), { recursive: true });
|
|
125
|
+
const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
|
|
126
|
+
fs.writeFileSync(cf, JSON.stringify(out), 'utf8');
|
|
127
|
+
log(`[基线] 已缓存 ${m.size} 只 → ${path.basename(cf)}`);
|
|
128
|
+
}
|
|
129
|
+
return m;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- 排除项 ----------
|
|
133
|
+
function excludeOne(q, map, tradeDate) {
|
|
134
|
+
const ex = cfg.exclude;
|
|
135
|
+
if (ex.st && /ST|退/i.test(q.name)) return 'ST/退市风险';
|
|
136
|
+
if (ex.suspended) {
|
|
137
|
+
const hhmmss = String(q.stamp || '').slice(8, 14);
|
|
138
|
+
if (!(q.vol > 0) || hhmmss === '090000') return '停牌/无成交';
|
|
139
|
+
}
|
|
140
|
+
if (ex.newListing) {
|
|
141
|
+
if (/^[NC]/.test(q.name)) return '新股/次新';
|
|
142
|
+
const listed = listedOf(map, q.code);
|
|
143
|
+
if (listed && tradeDate) {
|
|
144
|
+
const days = Math.abs(dayjs.tz(tradeDate + ' 00:00:00').diff(dayjs.tz(listed.slice(0, 10) + ' 00:00:00'), 'day'));
|
|
145
|
+
if (days < ex.newListingDays) return '上市过短';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------- git 推送(溯源)----------
|
|
152
|
+
function gitPush(files, message) {
|
|
153
|
+
const root = paths.PKG_ROOT;
|
|
154
|
+
const run = (args, allowFail) => {
|
|
155
|
+
try { return execFileSync('git', args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }).toString(); }
|
|
156
|
+
catch (e) { if (allowFail) return ''; throw e; }
|
|
157
|
+
};
|
|
158
|
+
run(['add', ...files]);
|
|
159
|
+
run(['-c', 'core.hooksPath=.git/cph', 'commit', '-m', message]);
|
|
160
|
+
const sha = run(['rev-parse', 'HEAD']).trim();
|
|
161
|
+
run(['-c', 'credential.helper=', '-c', 'credential.helper=wincred', 'push', 'origin', `${sha}:refs/heads/main`]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- 主流程 ----------
|
|
165
|
+
async function run() {
|
|
166
|
+
if (OPT.paths) {
|
|
167
|
+
const d = paths.describe();
|
|
168
|
+
log(`工作目录: ${d.home} (来源 ${d.source})`);
|
|
169
|
+
for (const [k, v] of Object.entries(d.workspace)) log(` ${k.padEnd(14)} ${v}`);
|
|
170
|
+
return { paths: d };
|
|
171
|
+
}
|
|
172
|
+
if (OPT.init) { scaffold.autoInit({ log, init: true }); return { init: true }; }
|
|
173
|
+
scaffold.autoInit({ log });
|
|
174
|
+
|
|
175
|
+
const now = OPT.now
|
|
176
|
+
? dayjs.tz(OPT.now.replace(' ', 'T') + '+08:00')
|
|
177
|
+
: dayjs.tz();
|
|
178
|
+
const anchor = lastClosedTradingDay(now); // 前一日 K 线锚定日
|
|
179
|
+
|
|
180
|
+
// 仅预计算基线缓存(供全市场次日盘前用,建议收盘后定时跑)
|
|
181
|
+
if (OPT.baseline) {
|
|
182
|
+
const scope = argOf('--scope') || (OPT.universe ? 'universe' : cfg.scope.default);
|
|
183
|
+
let codes;
|
|
184
|
+
if (scope === 'universe') {
|
|
185
|
+
// 全市场:先抓快照枚举「活跃」代码(~5000),只对活跃票抓 K 线;
|
|
186
|
+
// 直接 expandCodes 会盲扫 3 万槽位(含大量 inactive),K 线逐个抓取会超时。
|
|
187
|
+
log('[基线] 全市场模式:先抓快照枚举活跃代码…');
|
|
188
|
+
const snap = await market.fullMarketSnapshot(cfg, log);
|
|
189
|
+
codes = snap.quotes.map((q) => q.code);
|
|
190
|
+
log(`[基线] 活跃代码 ${codes.length} 只(交易日 ${snap.meta.marketDate})`);
|
|
191
|
+
} else {
|
|
192
|
+
codes = readCandidates().map((c) => c.code);
|
|
193
|
+
}
|
|
194
|
+
log(`[基线预计算] scope=${scope} 共 ${codes.length} 只,锚定日 ${anchor}`);
|
|
195
|
+
const m = new Map();
|
|
196
|
+
let done = 0;
|
|
197
|
+
const res = await mapLimit(codes, cfg.market.concurrency, async (code) => {
|
|
198
|
+
try {
|
|
199
|
+
const kl = await market.fetchKline(code, anchor);
|
|
200
|
+
const b = PR.calcBaseline(kl, code, cfg);
|
|
201
|
+
if ((++done) % 500 === 0) log(`[基线] 进度 ${done}/${codes.length}`);
|
|
202
|
+
return [code, b];
|
|
203
|
+
} catch (e) { return [code, { error: String((e && e.message) || e) }]; }
|
|
204
|
+
});
|
|
205
|
+
let ok = 0, err = 0;
|
|
206
|
+
for (const [c, b] of res) { if (b && !b.error) { m.set(c, b); ok++; } else err++; }
|
|
207
|
+
const out = {}; for (const [c, b] of m) out[c] = b;
|
|
208
|
+
const cf = path.join(paths.eodDir(), cfg.baseline.dataDir, cfg.baseline.fileName(anchor));
|
|
209
|
+
fs.mkdirSync(path.dirname(cf), { recursive: true });
|
|
210
|
+
fs.writeFileSync(cf, JSON.stringify(out), 'utf8');
|
|
211
|
+
log(`[基线] 已缓存 ${ok} 只(失败 ${err})→ ${path.basename(cf)}`);
|
|
212
|
+
return { baseline: { file: cf, ok, err } };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 时段守卫(本地时钟,第一遍)
|
|
216
|
+
let ss = sessionState({ now, force: OPT.force });
|
|
217
|
+
log(`[时段] ${ss.reason}`);
|
|
218
|
+
if (!ss.ok) { log('[结束] 跳过'); return { skipped: ss.reason }; }
|
|
219
|
+
|
|
220
|
+
// 范围与代码
|
|
221
|
+
const scope = argOf('--scope') || (OPT.universe ? 'universe' : cfg.scope.default);
|
|
222
|
+
let codes, quotes;
|
|
223
|
+
if (scope === 'watchlist') {
|
|
224
|
+
const watch = readCandidates();
|
|
225
|
+
codes = watch.map((w) => w.code);
|
|
226
|
+
log(`[1/4] 抓观察池竞价快照(${codes.length} 只)…`);
|
|
227
|
+
quotes = (await market.fetchBatch(codes, cfg)).filter((q) => q);
|
|
228
|
+
} else {
|
|
229
|
+
log('[1/4] 抓全市场竞价快照…');
|
|
230
|
+
const snap = await market.fullMarketSnapshot(cfg, log);
|
|
231
|
+
quotes = snap.quotes;
|
|
232
|
+
if (snap.meta.valid < cfg.market.minValidQuotes) throw new Error(`有效报价 ${snap.meta.valid} 低于红线 ${cfg.market.minValidQuotes}`);
|
|
233
|
+
codes = quotes.map((q) => q.code);
|
|
234
|
+
log(` 快照 ${snap.meta.valid} 只(交易日 ${snap.meta.marketDate})`);
|
|
235
|
+
// 第二遍时段守卫(用行情日期确认交易日)
|
|
236
|
+
ss = sessionState({ now, marketDate: snap.meta.marketDate, force: OPT.force });
|
|
237
|
+
if (!ss.ok) { log('[结束] 跳过'); return { skipped: ss.reason }; }
|
|
238
|
+
}
|
|
239
|
+
const tradeDate = ss.tradeDate;
|
|
240
|
+
|
|
241
|
+
// 基线
|
|
242
|
+
log('[2/4] 计算基线(昨涨 + 趋势)…');
|
|
243
|
+
const baseline = await getBaseline(codes, anchor, scope);
|
|
244
|
+
|
|
245
|
+
// 行业映射(分组用)
|
|
246
|
+
const map = loadIndustryMap();
|
|
247
|
+
if (map.count < 3000) log(`[警告] 行业映射仅 ${map.count} 只,分组可能失真`);
|
|
248
|
+
|
|
249
|
+
// 规则 + 打分
|
|
250
|
+
log('[3/4] 应用 P1/P2/P3 打分…');
|
|
251
|
+
const candidates = [];
|
|
252
|
+
const nearMissAll = [];
|
|
253
|
+
const gaps = [];
|
|
254
|
+
for (const q of quotes) {
|
|
255
|
+
const b = baseline.get(q.code);
|
|
256
|
+
if (!b) { gaps.push({ code: q.code, name: q.name, reason: '无基线K线' }); continue; }
|
|
257
|
+
const exR = excludeOne(q, map, tradeDate);
|
|
258
|
+
if (exR) { gaps.push({ code: q.code, name: q.name, reason: exR }); continue; }
|
|
259
|
+
|
|
260
|
+
const p1 = PR.calcP1(q, b, cfg);
|
|
261
|
+
q.__auctionChgPct = p1.auctionChgPct;
|
|
262
|
+
const p2 = PR.calcP2(q, b, cfg);
|
|
263
|
+
const p3 = PR.calcP3(b, cfg);
|
|
264
|
+
const sc = PR.scorePreopen(p1, p2, p3, cfg);
|
|
265
|
+
const sector = sectorOf(map, q.code) || UNKNOWN;
|
|
266
|
+
const rec = {
|
|
267
|
+
code: q.code, name: q.name, sector,
|
|
268
|
+
board: /sh68[89]/.test(q.code) ? '科创板' : /^sz30/.test(q.code) ? '创业板' : /^bj/.test(q.code) ? '北交所' : '主板',
|
|
269
|
+
open: q.open, prevClose: q.prevClose, price: q.price,
|
|
270
|
+
auctionChgPct: p1.auctionChgPct, auctionVolRatio: p1.auctionVolRatio,
|
|
271
|
+
yesterdayChgPct: b.yesterdayChgPct,
|
|
272
|
+
trend: { maBull: p3.maBull, newHigh20: p3.newHigh20, newHigh60: p3.newHigh60, slope20: p3.slope20 },
|
|
273
|
+
p1, p2, p3,
|
|
274
|
+
label: PR.labelOf(p1, p2, p3),
|
|
275
|
+
risk: p2.risk,
|
|
276
|
+
total: +sc.total.toFixed(1),
|
|
277
|
+
scoreItems: { p1: +p1.score.toFixed(2), p2: +p2.score.toFixed(2), p3: +p3.score.toFixed(2) },
|
|
278
|
+
stamp: q.stamp,
|
|
279
|
+
};
|
|
280
|
+
if (sc.total < cfg.score.minTotal) {
|
|
281
|
+
// 贴线样本(2026-10-22 补):总分落在 [minTotal-10, minTotal) 的进归档 nearMiss 段。
|
|
282
|
+
// 竞价量比等特征无法事后从 K 线重建,被拒样本不留就无法回溯调「入选线」本身。
|
|
283
|
+
if (sc.total >= cfg.score.minTotal - 10) {
|
|
284
|
+
nearMissAll.push(Object.assign(rec, { belowMin: +(cfg.score.minTotal - sc.total).toFixed(1) }));
|
|
285
|
+
}
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
candidates.push(rec);
|
|
289
|
+
}
|
|
290
|
+
candidates.sort((a, b) => b.total - a.total);
|
|
291
|
+
nearMissAll.sort((a, b) => b.total - a.total);
|
|
292
|
+
const nearMiss = nearMissAll.slice(0, 10);
|
|
293
|
+
// 各淘汰原因计数(gaps 明细不落盘,只留统计——6000+ 只/天的明细太肥且没用)
|
|
294
|
+
const rejByReason = {};
|
|
295
|
+
for (const g of gaps) rejByReason[g.reason] = (rejByReason[g.reason] || 0) + 1;
|
|
296
|
+
|
|
297
|
+
// 分组
|
|
298
|
+
const bySector = new Map();
|
|
299
|
+
for (const c of candidates) {
|
|
300
|
+
if (!bySector.has(c.sector)) bySector.set(c.sector, []);
|
|
301
|
+
bySector.get(c.sector).push(c);
|
|
302
|
+
}
|
|
303
|
+
const groups = [];
|
|
304
|
+
for (const [sector, list] of bySector) {
|
|
305
|
+
list.sort((a, b) => b.total - a.total);
|
|
306
|
+
list.forEach((c, i) => { c.groupRank = i + 1; c.bestInGroup = i === 0; c.groupSize = list.length; });
|
|
307
|
+
groups.push({ sector, size: list.length, shown: list.slice(0, cfg.group.maxPerGroup), best: list[0] });
|
|
308
|
+
}
|
|
309
|
+
groups.sort((a, b) => b.best.total - a.best.total);
|
|
310
|
+
|
|
311
|
+
const result = {
|
|
312
|
+
candidates, groups, top: candidates.slice(0, cfg.group.topN),
|
|
313
|
+
stats: { snapshotCount: quotes.length, baselineCount: baseline.size, candidateCount: candidates.length, groupCount: groups.length, gapCount: gaps.length, rejByReason },
|
|
314
|
+
gaps,
|
|
315
|
+
nearMiss,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
log(`[4/4] 落库 + 报告 + 通知…`);
|
|
319
|
+
return emitResult({ tradeDate, scope, anchor, result, baselineCount: baseline.size, quotesCount: quotes.length, marketDate: tradeDate, _t0: Date.now() });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function emitResult(p) {
|
|
323
|
+
const { tradeDate, scope, result, marketDate } = p;
|
|
324
|
+
const saved = store.saveRun({
|
|
325
|
+
cfg, tradeDate, scope, candidates: result.candidates,
|
|
326
|
+
nearMiss: result.nearMiss, rejByReason: result.stats.rejByReason,
|
|
327
|
+
meta: { snapshotCount: result.stats.snapshotCount, baselineCount: p.baselineCount, validQuotes: p.quotesCount, marketDate, elapsedMs: Date.now() - (p._t0 || Date.now()) },
|
|
328
|
+
marketDate,
|
|
329
|
+
});
|
|
330
|
+
log(`[落库] ${path.basename(saved.file)}(${saved.recordCount} 条 / 第 ${saved.runCount} 次运行)`);
|
|
331
|
+
|
|
332
|
+
const reportDir = path.join(paths.eodDir(), cfg.store.reportDir);
|
|
333
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
334
|
+
const suffix = OPT.stamp ? `-${dayjs.tz().format('HHmm')}` : '';
|
|
335
|
+
const reportFile = path.join(reportDir, `${cfg.store.prefix}-${tradeDate}${suffix}.html`);
|
|
336
|
+
fs.writeFileSync(reportFile, buildHTML({ cfg, tradeDate, scope, result, marketDate, anchor: p.anchor, runner: RUNNER }), 'utf8');
|
|
337
|
+
log(`[报告] ${reportFile}`);
|
|
338
|
+
|
|
339
|
+
if (cfg.notify.enabled && !OPT.noNotify) {
|
|
340
|
+
const top = result.top.slice(0, cfg.notify.topInMessage);
|
|
341
|
+
const lines = top.map((c, i) =>
|
|
342
|
+
`${i + 1}. ${c.name}(${c.code.replace(/^[a-z]+/, '')})${c.sector} · 竞价${c.auctionChgPct?.toFixed(2)}% · 昨${c.yesterdayChgPct?.toFixed(2)}% · ${c.label} · 总分${c.total}`);
|
|
343
|
+
const content = [
|
|
344
|
+
`交易日 ${tradeDate} · 盘前选股(${scope === 'universe' ? '全市场' : '观察池'})`,
|
|
345
|
+
`候选 ${result.stats.candidateCount} 只 / ${result.stats.groupCount} 行业(快照 ${result.stats.snapshotCount} → 基线 ${result.stats.baselineCount})`,
|
|
346
|
+
'', ...lines, '',
|
|
347
|
+
`完整报告见本地:eod/reports/${cfg.store.prefix}-${tradeDate}${suffix}.html`,
|
|
348
|
+
].join('\n');
|
|
349
|
+
const res = await notify({ title: `盘前选股 ${tradeDate}(${result.stats.candidateCount} 只)`, content, htmlPath: reportFile });
|
|
350
|
+
for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 推送远程溯源
|
|
354
|
+
if (!OPT.noPush) {
|
|
355
|
+
try {
|
|
356
|
+
const pushFiles = [saved.file, reportFile];
|
|
357
|
+
gitPush(pushFiles, `preopen: ${tradeDate} 候选 ${result.stats.candidateCount} 只(${scope})`);
|
|
358
|
+
log('[推送] 已 git 提交并推送远程(溯源)');
|
|
359
|
+
} catch (e) {
|
|
360
|
+
log(`[推送] 失败(数据已落本地,可稍后手动推送):${e && e.message || e}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const summary = {
|
|
365
|
+
tradeDate, scope,
|
|
366
|
+
snapshot: result.stats.snapshotCount, baseline: result.stats.baselineCount,
|
|
367
|
+
candidates: result.stats.candidateCount, groups: result.stats.groupCount,
|
|
368
|
+
top: result.top.slice(0, 10).map((c) => ({ code: c.code, name: c.name, total: c.total, label: c.label })),
|
|
369
|
+
reportFile, savedFile: saved.file,
|
|
370
|
+
};
|
|
371
|
+
if (OPT.json) console.log(JSON.stringify(summary, null, 2));
|
|
372
|
+
return summary;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
run().catch((e) => fail('盘前选股流程异常', e));
|