candidate-pool 2.2.0 → 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/CHANGELOG.md +62 -0
- package/eod/data/preopen-2026-10-22.json +1207 -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 +96 -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 +360 -0
- package/eod/tail.config.js +39 -18
- package/package.json +5 -1
|
@@ -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,360 @@
|
|
|
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 gaps = [];
|
|
253
|
+
for (const q of quotes) {
|
|
254
|
+
const b = baseline.get(q.code);
|
|
255
|
+
if (!b) { gaps.push({ code: q.code, name: q.name, reason: '无基线K线' }); continue; }
|
|
256
|
+
const exR = excludeOne(q, map, tradeDate);
|
|
257
|
+
if (exR) { gaps.push({ code: q.code, name: q.name, reason: exR }); continue; }
|
|
258
|
+
|
|
259
|
+
const p1 = PR.calcP1(q, b, cfg);
|
|
260
|
+
q.__auctionChgPct = p1.auctionChgPct;
|
|
261
|
+
const p2 = PR.calcP2(q, b, cfg);
|
|
262
|
+
const p3 = PR.calcP3(b, cfg);
|
|
263
|
+
const sc = PR.scorePreopen(p1, p2, p3, cfg);
|
|
264
|
+
if (sc.total < cfg.score.minTotal) continue;
|
|
265
|
+
|
|
266
|
+
const sector = sectorOf(map, q.code) || UNKNOWN;
|
|
267
|
+
candidates.push({
|
|
268
|
+
code: q.code, name: q.name, sector,
|
|
269
|
+
board: /sh68[89]/.test(q.code) ? '科创板' : /^sz30/.test(q.code) ? '创业板' : /^bj/.test(q.code) ? '北交所' : '主板',
|
|
270
|
+
open: q.open, prevClose: q.prevClose, price: q.price,
|
|
271
|
+
auctionChgPct: p1.auctionChgPct, auctionVolRatio: p1.auctionVolRatio,
|
|
272
|
+
yesterdayChgPct: b.yesterdayChgPct,
|
|
273
|
+
trend: { maBull: p3.maBull, newHigh20: p3.newHigh20, newHigh60: p3.newHigh60, slope20: p3.slope20 },
|
|
274
|
+
p1, p2, p3,
|
|
275
|
+
label: PR.labelOf(p1, p2, p3),
|
|
276
|
+
risk: p2.risk,
|
|
277
|
+
total: +sc.total.toFixed(1),
|
|
278
|
+
scoreItems: { p1: +p1.score.toFixed(2), p2: +p2.score.toFixed(2), p3: +p3.score.toFixed(2) },
|
|
279
|
+
stamp: q.stamp,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
candidates.sort((a, b) => b.total - a.total);
|
|
283
|
+
|
|
284
|
+
// 分组
|
|
285
|
+
const bySector = new Map();
|
|
286
|
+
for (const c of candidates) {
|
|
287
|
+
if (!bySector.has(c.sector)) bySector.set(c.sector, []);
|
|
288
|
+
bySector.get(c.sector).push(c);
|
|
289
|
+
}
|
|
290
|
+
const groups = [];
|
|
291
|
+
for (const [sector, list] of bySector) {
|
|
292
|
+
list.sort((a, b) => b.total - a.total);
|
|
293
|
+
list.forEach((c, i) => { c.groupRank = i + 1; c.bestInGroup = i === 0; c.groupSize = list.length; });
|
|
294
|
+
groups.push({ sector, size: list.length, shown: list.slice(0, cfg.group.maxPerGroup), best: list[0] });
|
|
295
|
+
}
|
|
296
|
+
groups.sort((a, b) => b.best.total - a.best.total);
|
|
297
|
+
|
|
298
|
+
const result = {
|
|
299
|
+
candidates, groups, top: candidates.slice(0, cfg.group.topN),
|
|
300
|
+
stats: { snapshotCount: quotes.length, baselineCount: baseline.size, candidateCount: candidates.length, groupCount: groups.length, gapCount: gaps.length },
|
|
301
|
+
gaps,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
log(`[4/4] 落库 + 报告 + 通知…`);
|
|
305
|
+
return emitResult({ tradeDate, scope, anchor, result, baselineCount: baseline.size, quotesCount: quotes.length, marketDate: tradeDate, _t0: Date.now() });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function emitResult(p) {
|
|
309
|
+
const { tradeDate, scope, result, marketDate } = p;
|
|
310
|
+
const saved = store.saveRun({
|
|
311
|
+
cfg, tradeDate, scope, candidates: result.candidates,
|
|
312
|
+
meta: { snapshotCount: result.stats.snapshotCount, baselineCount: p.baselineCount, validQuotes: p.quotesCount, marketDate, elapsedMs: Date.now() - (p._t0 || Date.now()) },
|
|
313
|
+
marketDate,
|
|
314
|
+
});
|
|
315
|
+
log(`[落库] ${path.basename(saved.file)}(${saved.recordCount} 条 / 第 ${saved.runCount} 次运行)`);
|
|
316
|
+
|
|
317
|
+
const reportDir = path.join(paths.eodDir(), cfg.store.reportDir);
|
|
318
|
+
fs.mkdirSync(reportDir, { recursive: true });
|
|
319
|
+
const suffix = OPT.stamp ? `-${dayjs.tz().format('HHmm')}` : '';
|
|
320
|
+
const reportFile = path.join(reportDir, `${cfg.store.prefix}-${tradeDate}${suffix}.html`);
|
|
321
|
+
fs.writeFileSync(reportFile, buildHTML({ cfg, tradeDate, scope, result, marketDate, anchor: p.anchor, runner: RUNNER }), 'utf8');
|
|
322
|
+
log(`[报告] ${reportFile}`);
|
|
323
|
+
|
|
324
|
+
if (cfg.notify.enabled && !OPT.noNotify) {
|
|
325
|
+
const top = result.top.slice(0, cfg.notify.topInMessage);
|
|
326
|
+
const lines = top.map((c, i) =>
|
|
327
|
+
`${i + 1}. ${c.name}(${c.code.replace(/^[a-z]+/, '')})${c.sector} · 竞价${c.auctionChgPct?.toFixed(2)}% · 昨${c.yesterdayChgPct?.toFixed(2)}% · ${c.label} · 总分${c.total}`);
|
|
328
|
+
const content = [
|
|
329
|
+
`交易日 ${tradeDate} · 盘前选股(${scope === 'universe' ? '全市场' : '观察池'})`,
|
|
330
|
+
`候选 ${result.stats.candidateCount} 只 / ${result.stats.groupCount} 行业(快照 ${result.stats.snapshotCount} → 基线 ${result.stats.baselineCount})`,
|
|
331
|
+
'', ...lines, '',
|
|
332
|
+
`完整报告见本地:eod/reports/${cfg.store.prefix}-${tradeDate}${suffix}.html`,
|
|
333
|
+
].join('\n');
|
|
334
|
+
const res = await notify({ title: `盘前选股 ${tradeDate}(${result.stats.candidateCount} 只)`, content, htmlPath: reportFile });
|
|
335
|
+
for (const [ch, [ok, info]] of res) log(`[notify] ${ch}: ${ok ? 'OK' : '失败 ' + JSON.stringify(info)}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 推送远程溯源
|
|
339
|
+
if (!OPT.noPush) {
|
|
340
|
+
try {
|
|
341
|
+
const pushFiles = [saved.file, reportFile];
|
|
342
|
+
gitPush(pushFiles, `preopen: ${tradeDate} 候选 ${result.stats.candidateCount} 只(${scope})`);
|
|
343
|
+
log('[推送] 已 git 提交并推送远程(溯源)');
|
|
344
|
+
} catch (e) {
|
|
345
|
+
log(`[推送] 失败(数据已落本地,可稍后手动推送):${e && e.message || e}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const summary = {
|
|
350
|
+
tradeDate, scope,
|
|
351
|
+
snapshot: result.stats.snapshotCount, baseline: result.stats.baselineCount,
|
|
352
|
+
candidates: result.stats.candidateCount, groups: result.stats.groupCount,
|
|
353
|
+
top: result.top.slice(0, 10).map((c) => ({ code: c.code, name: c.name, total: c.total, label: c.label })),
|
|
354
|
+
reportFile, savedFile: saved.file,
|
|
355
|
+
};
|
|
356
|
+
if (OPT.json) console.log(JSON.stringify(summary, null, 2));
|
|
357
|
+
return summary;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
run().catch((e) => fail('盘前选股流程异常', e));
|
package/eod/tail.config.js
CHANGED
|
@@ -103,15 +103,21 @@ module.exports = {
|
|
|
103
103
|
// 3. 初筛过滤(FR-03 / Q5–Q10)—— 尾盘口径 ≠ R01 隔夜口径,独立配置
|
|
104
104
|
// =========================================================================
|
|
105
105
|
filter: {
|
|
106
|
-
// 当日涨幅区间(%)。上限若放到 9% 会引入接近涨停买不到的标的(Q5
|
|
107
|
-
|
|
106
|
+
// 当日涨幅区间(%)。上限若放到 9% 会引入接近涨停买不到的标的(Q5)。
|
|
107
|
+
// 2026-10-22 回溯调参(docs/backtest/01-复盘.md):341 条候选分桶显示
|
|
108
|
+
// 2–3% 贴下限桶持一夜均 -1.11%、涨超 2% 命中率仅 8%(5–7% 桶 29%);
|
|
109
|
+
// 涨幅也是全部原始特征里与次日收益相关最强的(ρ=+0.150)。
|
|
110
|
+
// 故下限 2.0 → 3.0,把最弱的一段挡在门外。
|
|
111
|
+
chgPctMin: 3.0,
|
|
108
112
|
chgPctMax: 7.0,
|
|
109
113
|
// 量比下限(Q6)。14:50 的即时量比天然偏小,1.2 是「已有温和放量」的起点;
|
|
110
114
|
// 想更严可提到 1.5,想放宽可降到 1.0。
|
|
111
115
|
volRatioMin: 1.2,
|
|
112
|
-
// 换手率区间(%)(Q9
|
|
116
|
+
// 换手率区间(%)(Q9):过低无人气,过高警惕出货。
|
|
117
|
+
// 2026-10-22 回溯调参:10–20% 桶 30 只持一夜均 -3.70%、胜率仅 17%,
|
|
118
|
+
// 是全表最差的一段(换手与收益 ρ=-0.141)。上限 20 → 10。
|
|
113
119
|
turnoverMin: 2.0,
|
|
114
|
-
turnoverMax:
|
|
120
|
+
turnoverMax: 10.0,
|
|
115
121
|
// 流通市值区间(亿)(Q10):与 candidate-pool 现有口径一致,便于横向对照
|
|
116
122
|
floatCapMinYi: 20,
|
|
117
123
|
floatCapMaxYi: 500,
|
|
@@ -139,29 +145,44 @@ module.exports = {
|
|
|
139
145
|
},
|
|
140
146
|
|
|
141
147
|
// =========================================================================
|
|
142
|
-
// 5. 强度打分(FR-06 / Q11)—— 总分 100
|
|
148
|
+
// 5. 强度打分(FR-06 / Q11)—— 总分 100,五项可解释
|
|
149
|
+
// 2026-10-22 回溯调参(docs/backtest/01-复盘.md):
|
|
150
|
+
// · 删「板块强度」项:341 条候选中该项 100% 拿满 15 分(常量,零区分度),
|
|
151
|
+
// 还让环境组均分(32.8/35)反超个股自身组(29.1/65),分数被背景分主导;
|
|
152
|
+
// · 删除后余下五项按原权重比例重新分配并取整:30/20/15/10/10 → 35/24/18/12/11
|
|
153
|
+
// (不按相关系数加权——各分项 |ρ| 均 <0.1,那是噪声不是信号,比例分配最稳);
|
|
154
|
+
// · 总分本身与次日收益几乎不相关(ρ=+0.044),故新增 minTotal 只作粗过滤,
|
|
155
|
+
// 排序区分度请直接看涨幅(ρ=+0.150,报告已对 chg≥5% 加标记)。
|
|
143
156
|
// =========================================================================
|
|
144
157
|
score: {
|
|
145
158
|
weights: {
|
|
146
|
-
tailMomentum:
|
|
147
|
-
volume:
|
|
148
|
-
position:
|
|
149
|
-
avgLine:
|
|
150
|
-
|
|
151
|
-
turnoverFit: 10, // 换手适中度:3%–10% 满分,向两端递减
|
|
159
|
+
tailMomentum: 35, // 尾盘动能:14:30→14:50 涨幅线性映射,+2% 封顶
|
|
160
|
+
volume: 24, // 量能:量比 1.2 → 0 分,3.0 → 满分
|
|
161
|
+
position: 18, // 位置:距日内高点越近越高
|
|
162
|
+
avgLine: 12, // 均价线强度:现价 ÷ 均价 的超出幅度
|
|
163
|
+
turnoverFit: 11, // 换手适中度:3%–7% 满分,向两端递减
|
|
152
164
|
},
|
|
153
165
|
// 尾盘动能:段涨幅达到该值即拿满分(%)
|
|
154
166
|
tailMomentumFullPct: 2.0,
|
|
155
167
|
// 量能:达到该量比即满分
|
|
156
168
|
volRatioFull: 3.0,
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
//
|
|
169
|
+
// 均价线:超出均价该百分比即满分(%)。
|
|
170
|
+
// 2026-10-22 回溯调参:原 1.0% 时 89% 候选贴满分(均值 9.6/10),
|
|
171
|
+
// 该项已无区分度且与收益轻微负相关(ρ=-0.088)。满分线 1.0 → 1.5。
|
|
172
|
+
avgLineFullPct: 1.5,
|
|
173
|
+
// 换手适中区间(%):落在此区间满分。
|
|
174
|
+
// 2026-10-22 随 turnoverMax 20→10 同步由 10 → 7:
|
|
175
|
+
// 上限收窄后若甜区仍顶到 10%,3–10% 全满分,「过高警惕出货」的扣分就没了。
|
|
160
176
|
turnoverSweetLow: 3.0,
|
|
161
|
-
turnoverSweetHigh:
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
177
|
+
turnoverSweetHigh: 7.0,
|
|
178
|
+
// 候选入选总分下限(低于此不进报告)。
|
|
179
|
+
// 2026-10-22 回溯调参新增:旧分制下 45–55 分段 84 只持一夜均 -1.32%、命中 12%,
|
|
180
|
+
// 是全部分数段里最差的一段。删除 sector 常量项后总分整体下移约 7 分
|
|
181
|
+
// (均值 61.9 → 50.1),旧 55 的等效新总分 ≈ 47,故下限取 **47**
|
|
182
|
+
// (= 决策批准的切分线在新分制下的等效值;若照抄 55 会等效旧 ~59.4,
|
|
183
|
+
// 回溯显示边际收益仅 +1pt 命中率却再砍 36% 产量,不值)。
|
|
184
|
+
// 注:尾盘此前没有总分下限(初筛+分时校验过即入选),此项为新增。
|
|
185
|
+
minTotal: 47,
|
|
165
186
|
// 分时结构加分项(不计入 100 分,单独展示,避免权重膨胀)
|
|
166
187
|
// 段内上涨分钟占比低于该值时在报告中标注「尾盘拉升不连贯」
|
|
167
188
|
tailUpRatioWarn: 0.55,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "candidate-pool",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "A股短线选股工具包:次日候选池初筛(R01 量能验证突破 + R07 板块内补涨)与尾盘选股(14:50 口径),数据取自腾讯公开行情接口,输出单文件双语 HTML 报告",
|
|
5
5
|
"bin": {
|
|
6
6
|
"candidate-pool": "bin/candidate-pool.js",
|
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
"tail:intraday": "node eod/tail_screener.js --intraday",
|
|
54
54
|
"tail:replay": "node eod/tail_screener.js --replay",
|
|
55
55
|
"tail:selftest": "node eod/lib/trading.selftest.js",
|
|
56
|
+
"preopen": "node eod/preopen_screener.js",
|
|
57
|
+
"preopen:watchlist": "node eod/preopen_screener.js --scope watchlist",
|
|
58
|
+
"preopen:baseline": "node eod/preopen_screener.js --baseline",
|
|
59
|
+
"preopen:backfill": "node eod/preopen_backfill.js",
|
|
56
60
|
"paths": "node gen_candidates.js --paths",
|
|
57
61
|
"paths:selftest": "node paths.selftest.js",
|
|
58
62
|
"init": "node gen_candidates.js --init",
|