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.
- package/CHANGELOG.md +245 -0
- package/README.md +397 -0
- package/bin/candidate-pool.js +23 -0
- package/bin/tail-screener.js +22 -0
- package/build_universe.js +231 -0
- package/candidates.example.json +269 -0
- package/db/.env.example +13 -0
- package/db/backfill.js +71 -0
- package/db/clients.js +48 -0
- package/db/d1client.js +69 -0
- package/db/ddl.js +82 -0
- package/db/diff.js +223 -0
- package/db/diff_selftest.js +124 -0
- package/db/enrich_stock_base.js +138 -0
- package/db/migrate.js +177 -0
- package/db/normalize.js +129 -0
- package/db/persist.js +90 -0
- package/db/query_local.js +43 -0
- package/db/schema.sql +225 -0
- package/db/sqlite_client.js +81 -0
- package/db/verify_sync.js +107 -0
- package/db/write_live.js +54 -0
- package/eod/build_industry_map.js +146 -0
- package/eod/data/industry-map.json +1 -0
- package/eod/lib/http.js +188 -0
- package/eod/lib/market.js +345 -0
- package/eod/lib/report.js +329 -0
- package/eod/lib/screen.js +355 -0
- package/eod/lib/sector.js +96 -0
- package/eod/lib/store.js +204 -0
- package/eod/lib/store_d1.js +293 -0
- package/eod/lib/trading.js +252 -0
- package/eod/lib/trading.selftest.js +283 -0
- package/eod/tail.config.js +212 -0
- package/eod/tail_screener.js +373 -0
- package/gen_candidates.js +994 -0
- package/notify.js +236 -0
- package/notify_config.example.json +14 -0
- package/package.json +96 -0
- package/paths.js +127 -0
- package/paths.selftest.js +142 -0
- package/scaffold.js +199 -0
- package/scaffold.selftest.js +153 -0
- package/time.js +17 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/trading.selftest.js —— 时段守卫与三种口径的回归自测
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 为什么必须有:`sessionState()` 决定「这一刻能不能跑、按哪个口径取数」,
|
|
6
|
+
* 它同时承担两个互相矛盾的目标:
|
|
7
|
+
* · 守卫 —— 定时任务在 14:30 之前触发时必须**拒绝**(否则产出 0 候选空报告并推送);
|
|
8
|
+
* · 盘中模式 —— 人工加 --intraday 时必须**放行**(否则盘中自查无从下手)。
|
|
9
|
+
* 这类「一个开关控制相反行为」的逻辑极易被后人顺手放开或收紧,
|
|
10
|
+
* 故用可重跑的断言把两条路径都钉住。
|
|
11
|
+
*
|
|
12
|
+
* 运行:node eod/lib/trading.selftest.js (退出码非 0 = 失败)
|
|
13
|
+
* 纯函数 + 注入时刻,不联网、不写任何文件。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { dayjs } = require('../../time');
|
|
17
|
+
const { sessionState, clampToTradedMinute, backTradedMinutes, fmtMin } = require('./trading');
|
|
18
|
+
// 端到端那一节要用真实的 tailMetrics + 合成分时(不联网):只有它才能证明
|
|
19
|
+
// 「起点 K 线真的存在」,而不是只证明字符串算对了。
|
|
20
|
+
const { tailMetrics } = require('./market');
|
|
21
|
+
|
|
22
|
+
const cfg = require('../tail.config');
|
|
23
|
+
const TZ = 'Asia/Shanghai';
|
|
24
|
+
|
|
25
|
+
let failed = 0;
|
|
26
|
+
function check(name, cond, detail) {
|
|
27
|
+
console.log(`${cond ? ' OK ' : ' '}${name}${cond || detail === undefined ? '' : ` → ${detail}`}`);
|
|
28
|
+
if (!cond) failed++;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 造一个北京时间时刻 */
|
|
32
|
+
const at = (s) => dayjs.tz(`2026-09-21 ${s}`, TZ);
|
|
33
|
+
/** 周六 / 周日(2026-09-19 / 09-20) */
|
|
34
|
+
const sat = (s) => dayjs.tz(`2026-09-19 ${s}`, TZ);
|
|
35
|
+
|
|
36
|
+
function run(opts) { return sessionState({ cfg, ...opts }); }
|
|
37
|
+
|
|
38
|
+
console.log('\n[1] 守卫:不加 --intraday 时,14:30 之前必须拒绝');
|
|
39
|
+
for (const t of ['09:35', '11:40', '13:10', '14:29']) {
|
|
40
|
+
const r = run({ now: at(t) });
|
|
41
|
+
check(`${t} 无参数 → ok=false`, r.ok === false, `ok=${r.ok} mode=${r.mode} reason=${r.reason}`);
|
|
42
|
+
check(`${t} 拒绝理由提示 --intraday`, /--intraday/.test(r.reason), r.reason);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log('\n[2] 盘中模式:加 --intraday 后必须放行,且段=滚动窗口');
|
|
46
|
+
const i1120 = run({ now: at('11:20'), intraday: true });
|
|
47
|
+
check('11:20(盘中)→ ok=true', i1120.ok === true, JSON.stringify(i1120));
|
|
48
|
+
check('11:20(盘中)→ mode=intraday', i1120.mode === 'intraday', i1120.mode);
|
|
49
|
+
check('11:20(盘中)→ cut=11:20', i1120.cutTime === '11:20', i1120.cutTime);
|
|
50
|
+
check('11:20(盘中)→ segFrom=1100(20 分钟窗口)', i1120.segFrom === '1100', i1120.segFrom);
|
|
51
|
+
|
|
52
|
+
// 注意 11:40 已过 11:30,属**午休**:分时里没有 11:31–12:59 的 K 线,
|
|
53
|
+
// 故 cut 必须夹到 11:30(而不是显示 11:40 却取到 11:30 的数据 → 误导)。
|
|
54
|
+
const i1140 = run({ now: at('11:40'), intraday: true });
|
|
55
|
+
check('11:40(午休)→ cut 夹到 11:30', i1140.cutTime === '11:30', i1140.cutTime);
|
|
56
|
+
check('11:40(午休)→ segFrom=1110', i1140.segFrom === '1110', i1140.segFrom);
|
|
57
|
+
|
|
58
|
+
const i1230 = run({ now: at('12:30'), intraday: true });
|
|
59
|
+
check('12:30(午休)→ cut 夹到 11:30', i1230.cutTime === '11:30', i1230.cutTime);
|
|
60
|
+
check('12:30(午休)→ segFrom=1110', i1230.segFrom === '1110', i1230.segFrom);
|
|
61
|
+
|
|
62
|
+
const i0935 = run({ now: at('09:35'), intraday: true });
|
|
63
|
+
check('09:35 → segFrom 不早于开盘 09:30', i0935.segFrom === '0930', i0935.segFrom);
|
|
64
|
+
check('09:35 → cut=09:35', i0935.cutTime === '09:35', i0935.cutTime);
|
|
65
|
+
|
|
66
|
+
// 跨午休:13:10 的「最近 20 分钟」**不是** 12:50(午休里没有 K 线),
|
|
67
|
+
// 而是 11:20→11:30 + 13:00→13:10。曾经这里写的是墙钟减法 `cut - 20`,
|
|
68
|
+
// 结果起点落进午休、tailMetrics 取不到起点 K 线 → 下午开盘后 20 分钟内跑盘中模式
|
|
69
|
+
// 静默 0 候选。下面的断言把这个坑钉死。
|
|
70
|
+
const i1310 = run({ now: at('13:10'), intraday: true });
|
|
71
|
+
check('13:10 → cut=13:10', i1310.cutTime === '13:10', i1310.cutTime);
|
|
72
|
+
check('13:10 → segFrom=1120(跨午休,不是 1250)', i1310.segFrom === '1120', i1310.segFrom);
|
|
73
|
+
|
|
74
|
+
const i1300 = run({ now: at('13:00'), intraday: true });
|
|
75
|
+
check('13:00(刚开盘)→ segFrom=1110(退到上午)', i1300.segFrom === '1110', i1300.segFrom);
|
|
76
|
+
|
|
77
|
+
const i1305 = run({ now: at('13:05'), intraday: true });
|
|
78
|
+
check('13:05 → segFrom=1115', i1305.segFrom === '1115', i1305.segFrom);
|
|
79
|
+
|
|
80
|
+
const i1329s30 = run({ now: at('13:29'), intraday: true, segMinutes: 30 });
|
|
81
|
+
check('13:29 + seg=30 → segFrom=1129(下午 29 分 + 上午 1 分)', i1329s30.segFrom === '1129', i1329s30.segFrom);
|
|
82
|
+
|
|
83
|
+
const i1400 = run({ now: at('14:00'), intraday: true });
|
|
84
|
+
check('14:00(下午盘中)→ segFrom=1340(不跨午休)', i1400.segFrom === '1340', i1400.segFrom);
|
|
85
|
+
|
|
86
|
+
// --seg-minutes 的显式覆盖必须真生效(不只是记在元数据里)
|
|
87
|
+
const i1335s40 = run({ now: at('13:35'), intraday: true, segMinutes: 40 });
|
|
88
|
+
check('13:35 + seg=40 → segFrom=1125(下午 35 分 + 上午 5 分)', i1335s40.segFrom === '1125', i1335s40.segFrom);
|
|
89
|
+
check('13:35 默认 seg=20 → segFrom=1315(对照:同一时刻窗口更短,确实被参数改变了)',
|
|
90
|
+
run({ now: at('13:35'), intraday: true }).segFrom === '1315',
|
|
91
|
+
run({ now: at('13:35'), intraday: true }).segFrom);
|
|
92
|
+
check('segMinutes 会随返回值带出(供存档 / 文件命名)', i1329s30.segMinutes === 30, String(i1329s30.segMinutes));
|
|
93
|
+
check('正式/观察口径 segMinutes 为 undefined(不是滚动窗口)',
|
|
94
|
+
run({ now: at('14:35') }).segMinutes === undefined, String(run({ now: at('14:35') }).segMinutes));
|
|
95
|
+
|
|
96
|
+
console.log('\n[3] 盘中模式不夺权:14:30 之后仍走观察/正式口径');
|
|
97
|
+
const o1435 = run({ now: at('14:35') });
|
|
98
|
+
check('14:35 无参数 → observe', o1435.mode === 'observe', o1435.mode);
|
|
99
|
+
check('14:35 无参数 → segFrom=1430(固定起点)', o1435.segFrom === '1430', o1435.segFrom);
|
|
100
|
+
const i1435 = run({ now: at('14:35'), intraday: true });
|
|
101
|
+
check('14:35 加 --intraday → 仍 observe(开关无副作用)', i1435.mode === 'observe', i1435.mode);
|
|
102
|
+
check('14:35 加 --intraday → 仍 observe 且 segFrom=1430', i1435.segFrom === '1430', i1435.segFrom);
|
|
103
|
+
|
|
104
|
+
const c1455 = run({ now: at('14:55') });
|
|
105
|
+
check('14:55 → cut', c1455.mode === 'cut', c1455.mode);
|
|
106
|
+
check('14:55 → cutTime=14:50', c1455.cutTime === '14:50', c1455.cutTime);
|
|
107
|
+
check('14:55 → segFrom=1430', c1455.segFrom === '1430', c1455.segFrom);
|
|
108
|
+
const c1600 = run({ now: at('16:00') });
|
|
109
|
+
check('16:00 → cut(收盘后宽限期内补跑)', c1600.ok === true && c1600.mode === 'cut', JSON.stringify(c1600.mode));
|
|
110
|
+
|
|
111
|
+
console.log('\n[4] 盘中模式也守交易日边界');
|
|
112
|
+
check('周六 11:00 + --intraday → 拒绝', run({ now: sat('11:00'), intraday: true }).ok === false);
|
|
113
|
+
check('09:00 + --intraday → 拒绝(早于开盘)', run({ now: at('09:00'), intraday: true }).ok === false);
|
|
114
|
+
check('18:00 + --intraday → 拒绝(超宽限期)', run({ now: at('18:00'), intraday: true }).ok === false);
|
|
115
|
+
check('14:40 行情日期为上一交易日 → 拒绝',
|
|
116
|
+
run({ now: at('14:40'), marketDate: '2026-09-18' }).ok === false);
|
|
117
|
+
|
|
118
|
+
console.log('\n[5] 开关可被配置彻底禁用(intraday.enabled=false)');
|
|
119
|
+
const cfgOff = { ...cfg, session: { ...cfg.session, intraday: { enabled: false, segMinutes: 20 } } };
|
|
120
|
+
const off = sessionState({ cfg: cfgOff, now: at('11:40'), intraday: true });
|
|
121
|
+
check('enabled=false 时传参也拒绝', off.ok === false, JSON.stringify(off.reason));
|
|
122
|
+
check('默认配置里 intraday.enabled 为 true', cfg.session.intraday.enabled === true);
|
|
123
|
+
|
|
124
|
+
console.log('\n[6] --force 语义不变');
|
|
125
|
+
const f = run({ now: at('11:40'), force: true });
|
|
126
|
+
check('force → ok=true / mode=cut', f.ok === true && f.mode === 'cut', `${f.mode}`);
|
|
127
|
+
check('force → segFrom 固定 1430', f.segFrom === '1430', f.segFrom);
|
|
128
|
+
|
|
129
|
+
console.log('\n[7] 辅助函数');
|
|
130
|
+
check('clampToTradedMinute: 12:00 → 11:30', clampToTradedMinute(12 * 60) === 11 * 60 + 30);
|
|
131
|
+
check('clampToTradedMinute: 13:00 → 13:00', clampToTradedMinute(13 * 60) === 13 * 60);
|
|
132
|
+
check('clampToTradedMinute: 15:30 → 15:00', clampToTradedMinute(15 * 60 + 30) === 15 * 60);
|
|
133
|
+
check('clampToTradedMinute: 08:00 → 09:30', clampToTradedMinute(8 * 60) === 9 * 60 + 30);
|
|
134
|
+
check('fmtMin: 555 → 09:15', fmtMin(555) === '09:15');
|
|
135
|
+
// backTradedMinutes:起点必须落在**已成交**分钟上(午休区间内一律非法)
|
|
136
|
+
const inLunch = (hhmm) => {
|
|
137
|
+
const m = (+hhmm.slice(0, 2)) * 60 + (+hhmm.slice(2, 4));
|
|
138
|
+
return m > 11 * 60 + 30 && m < 13 * 60;
|
|
139
|
+
};
|
|
140
|
+
check('backTradedMinutes: 13:10 退 20 → 11:20', backTradedMinutes(13 * 60 + 10, 20) === 11 * 60 + 20);
|
|
141
|
+
check('backTradedMinutes: 13:10 退 30 → 11:10', backTradedMinutes(13 * 60 + 10, 30) === 11 * 60 + 10);
|
|
142
|
+
check('backTradedMinutes: 14:00 退 20 → 13:40', backTradedMinutes(14 * 60, 20) === 13 * 60 + 40);
|
|
143
|
+
check('backTradedMinutes: 11:20 退 20 → 11:00', backTradedMinutes(11 * 60 + 20, 20) === 11 * 60);
|
|
144
|
+
check('backTradedMinutes: 09:35 退 20 → 09:30(不早于开盘)', backTradedMinutes(9 * 60 + 35, 20) === 9 * 60 + 30);
|
|
145
|
+
check('backTradedMinutes: 12:30(午休)退 20 → 11:10', backTradedMinutes(12 * 60 + 30, 20) === 11 * 60 + 10);
|
|
146
|
+
// 全时段扫描:任何 cut / 任何窗口,起点都不得落进午休
|
|
147
|
+
let lunchHits = 0;
|
|
148
|
+
for (let cut = 9 * 60 + 30; cut <= 15 * 60; cut++) {
|
|
149
|
+
for (const n of [1, 5, 20, 30, 60, 120]) {
|
|
150
|
+
const f = backTradedMinutes(cut, n);
|
|
151
|
+
if (inLunch(fmtMin(f).replace(':', ''))) lunchHits++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
check('backTradedMinutes: 全天 × 各窗口长度扫描,起点均不落午休', lunchHits === 0, `${lunchHits} 处落入午休`);
|
|
155
|
+
|
|
156
|
+
console.log('\n[8] 端到端:下午刚开盘时起点 K 线必须真的存在(历史 bug:静默 0 候选)');
|
|
157
|
+
// 造一天的合法分时:09:30–11:30 + 13:00–15:00(午休无 K 线,与真实接口一致)
|
|
158
|
+
function mkMinuteRows() {
|
|
159
|
+
const rows = [];
|
|
160
|
+
let p = 10;
|
|
161
|
+
const push = (m) => {
|
|
162
|
+
p = +(p * 1.0005).toFixed(3);
|
|
163
|
+
rows.push({ t: `${String(Math.floor(m / 60)).padStart(2, '0')}${String(m % 60).padStart(2, '0')}`, price: p, cumVol: 1000, cumAmt: p * 1000 });
|
|
164
|
+
};
|
|
165
|
+
for (let m = 9 * 60 + 30; m <= 11 * 60 + 30; m++) push(m);
|
|
166
|
+
for (let m = 13 * 60; m <= 15 * 60; m++) push(m);
|
|
167
|
+
return rows;
|
|
168
|
+
}
|
|
169
|
+
const rows = mkMinuteRows();
|
|
170
|
+
for (const t of ['09:40', '10:30', '11:20', '11:45', '12:30', '13:00', '13:05', '13:10', '13:29', '14:00']) {
|
|
171
|
+
const ss = run({ now: at(t), intraday: true });
|
|
172
|
+
const tm = tailMetrics(rows, 'sz000001', ss.cutTime.replace(':', ''), ss.segFrom);
|
|
173
|
+
check(`${t} → segFrom=${ss.segFrom} 能算出段指标`, tm !== null && tm.segPct !== undefined,
|
|
174
|
+
tm ? `segPct=${tm.segPct.toFixed(2)}% bars=${tm.bars}` : 'null(会被判 data_gap 剔除)');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log('\n[9] 落库口径:内部口径必须翻译成 D1/API 的 formal / observe');
|
|
178
|
+
// 历史 bug(2026-09-21):本地内部把「14:50 固定口径」叫 `cut`,落库时原样写进 D1 →
|
|
179
|
+
// · 记录页把正式运行显示成「观察」(前端 else 兜底);
|
|
180
|
+
// · /api/tail/run?mode=cut 被 worker 拒成 10003「mode 只能为 formal / observe」;
|
|
181
|
+
// · 口径对照页找不到 formal 行 → 整页为空。
|
|
182
|
+
{
|
|
183
|
+
const { d1Mode } = require('./store_d1');
|
|
184
|
+
check('cut → formal(14:50 固定口径的旧叫法)', d1Mode('cut') === 'formal', d1Mode('cut'));
|
|
185
|
+
check('formal → formal(幂等,重跑不会写成别的)', d1Mode('formal') === 'formal');
|
|
186
|
+
check('observe → observe', d1Mode('observe') === 'observe');
|
|
187
|
+
let threw = false;
|
|
188
|
+
try { d1Mode('intraday'); } catch (_) { threw = true; }
|
|
189
|
+
check('未知口径直接抛错(绝不往 D1 写第三种口径)', threw);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
console.log('\n[10] 落库 SQL:列数 / 占位符数 / 参数数 必须三者一致,且列清单对齐 schema.sql');
|
|
193
|
+
// 历史 bug(2026-09-21):tail_pick 列清单 33 列但 VALUES 手写了 32 个 `?`
|
|
194
|
+
// → 每条候选插入报 `7500 32 values for 33 columns`;db/d1client 的 batch 是
|
|
195
|
+
// 「分片 Promise.all」不是事务,tail_run 在第一批已提交 → 线上只剩运行记录、
|
|
196
|
+
// 候选一条没有(网页主表 / 分行业明细 / 口径对照全空)。
|
|
197
|
+
// 现在占位符由列清单派生,这里再把「列清单 vs schema.sql」钉住,
|
|
198
|
+
// 防止 schema 加了列而 store_d1 没跟上(反向漂移同样会让插入失败)。
|
|
199
|
+
{
|
|
200
|
+
const fs = require('fs');
|
|
201
|
+
const path = require('path');
|
|
202
|
+
const { SQL, COLS, buildStatements } = require('./store_d1');
|
|
203
|
+
|
|
204
|
+
for (const [name, sql] of Object.entries(SQL)) {
|
|
205
|
+
const cols = sql.match(/INSERT INTO \w+ \(([^)]*)\)/)[1].split(',').length;
|
|
206
|
+
const holders = sql.match(/VALUES \(([^)]*)\)/)[1].split(',').length;
|
|
207
|
+
check(`${name}:占位符数(${holders}) = 列数(${cols})`, holders === cols);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 从 db/schema.sql(唯一真相源)解析建表列名
|
|
211
|
+
const schema = fs.readFileSync(path.join(__dirname, '..', '..', 'db', 'schema.sql'), 'utf8');
|
|
212
|
+
const schemaCols = (table) => {
|
|
213
|
+
const m = schema.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table} \\(([\\s\\S]*?)\\n\\);`));
|
|
214
|
+
if (!m) return null;
|
|
215
|
+
return m[1].split('\n')
|
|
216
|
+
.map((l) => l.replace(/--.*$/, '').trim())
|
|
217
|
+
.filter((l) => l && !/^(PRIMARY|UNIQUE|FOREIGN|CONSTRAINT|CHECK)\b/i.test(l))
|
|
218
|
+
.map((l) => l.split(/\s+/)[0].replace(/["`[\]]/g, ''));
|
|
219
|
+
};
|
|
220
|
+
for (const [table, colList] of [['tail_run', COLS.RUN_COLS], ['tail_pick', COLS.PICK_COLS]]) {
|
|
221
|
+
const fromSchema = schemaCols(table);
|
|
222
|
+
check(`${table}:列清单与 schema.sql 完全一致(顺序敏感)`,
|
|
223
|
+
fromSchema !== null && JSON.stringify(fromSchema) === JSON.stringify(colList),
|
|
224
|
+
fromSchema === null ? 'schema.sql 未解析到该表' : `schema=${fromSchema.length} 列 / 代码=${colList.length} 列`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 真造一份 doc 走 buildStatements,断言每条语句的 params 数与占位符数相等
|
|
228
|
+
const doc = {
|
|
229
|
+
tradeDate: '2026-01-01', mode: 'cut', cutAt: '1450', updatedAt: '2026-01-01 14:50:00',
|
|
230
|
+
runs: [{ at: '2026-01-01 14:50:00', runner: 'local' }],
|
|
231
|
+
stats: { candidateCount: 1, groupCount: 1, prePassCount: 2, snapshotCount: 3 },
|
|
232
|
+
records: {
|
|
233
|
+
sh600000: {
|
|
234
|
+
code: 'sh600000', name: '浦发银行', sector: '银行', board: 'sh', boardLabel: '上海',
|
|
235
|
+
price: 10, prevClose: 9.9, high: 10.1, chgPct: 1.01, turnover: 1.2, volRatio: 1.3,
|
|
236
|
+
volRatioEst: 1.4, floatCapYi: 100, avgPrice: 9.95, groupRank: 1, bestInGroup: true,
|
|
237
|
+
groupSize: 1, total: 88, sectorMedianChg: 0.5, sectorRank: 1, sectorTotal: 1,
|
|
238
|
+
tail: { segPct: 1.2, upRatio: 0.7, maxDrawdownPct: -0.3, priceVsAvgPct: 0.5, avgAtCut: 9.95, p0: 9.9, p1: 10, bars: [9.9, 10] },
|
|
239
|
+
score: { momentum: 25 }, fill: {},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
const built = buildStatements(doc);
|
|
244
|
+
const stmts = [built.runStmt, ...built.pickStmts];
|
|
245
|
+
check(`buildStatements 产出 1 行运行 + 1 行候选`, stmts.length === 2);
|
|
246
|
+
const mismatch = stmts.filter((s) => {
|
|
247
|
+
const holders = s.sql.match(/VALUES \(([^)]*)\)/)[1].split(',').length;
|
|
248
|
+
return s.params.length !== holders;
|
|
249
|
+
});
|
|
250
|
+
check('每条的 params 数 = 占位符数', mismatch.length === 0,
|
|
251
|
+
mismatch.length ? `${mismatch.length} 条不匹配(首条 params=${mismatch[0].params.length})` : '');
|
|
252
|
+
check('口径已翻译:buildStatements 里没有 cut', !JSON.stringify(built.runStmt.params).includes('cut'));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log('\n[11] 落库文件名:观察 / 盘中默认一天一个(不带时点),--stamp 才带');
|
|
256
|
+
// 历史做法:观察模式按 `-obs<HHMM>` 命名 → 文件名取决于启动分钟(同一天 14:30 与 14:32 两次
|
|
257
|
+
// 观察会落成两个文件),文档 / --replay 无法写死,且与 D1「(trade_date, mode) 一天一行」的
|
|
258
|
+
// 粒度对不上。现与盘中统一:默认一天一个,多次运行靠 runs[] 留痕,时点另存 doc.cutAt。
|
|
259
|
+
// 放在本套自检里,是因为 `npm run tail:selftest` 就是尾盘的唯一自检入口(无 store.selftest.js)。
|
|
260
|
+
{
|
|
261
|
+
const { daySuffix, dayFile } = require('./store');
|
|
262
|
+
const cfg = { store: { dataDir: 'data' } };
|
|
263
|
+
|
|
264
|
+
check('正式 → 无后缀', daySuffix('cut', '1450') === '');
|
|
265
|
+
check('观察 → -obs(不含时点)', daySuffix('observe', '1431') === '-obs');
|
|
266
|
+
check('观察 + --stamp → -obs1431', daySuffix('observe', '1431', { stamp: true }) === '-obs1431');
|
|
267
|
+
check('盘中 → -intraday(不含时点)', daySuffix('intraday', '1310') === '-intraday');
|
|
268
|
+
check('盘中 + --stamp → 带时点与窗口', daySuffix('intraday', '1310', { stamp: true, segMinutes: 20 }) === '-intraday1310-w20');
|
|
269
|
+
|
|
270
|
+
// 核心不变量:默认文件名与运行时点无关(同一天任意时刻 → 同一个文件)
|
|
271
|
+
const pathA = dayFile(cfg, '2026-09-21', 'observe', '1431');
|
|
272
|
+
const pathB = dayFile(cfg, '2026-09-21', 'observe', '1445');
|
|
273
|
+
check('观察:不同时点落到同一文件(一天一个)', pathA === pathB && pathA.endsWith('eod-2026-09-21-obs.json'), pathA);
|
|
274
|
+
check('盘中:不同时点落到同一文件(一天一个)',
|
|
275
|
+
dayFile(cfg, '2026-09-21', 'intraday', '1030') === dayFile(cfg, '2026-09-21', 'intraday', '1400'));
|
|
276
|
+
check('观察 / 盘中 / 正式三者互不覆盖',
|
|
277
|
+
new Set([pathA, dayFile(cfg, '2026-09-21', 'intraday', '1431'), dayFile(cfg, '2026-09-21', 'cut', '1450')]).size === 3);
|
|
278
|
+
check('--stamp 让观察按时点分文件(显式要求才分)',
|
|
279
|
+
dayFile(cfg, '2026-09-21', 'observe', '1431', { stamp: true }) !== dayFile(cfg, '2026-09-21', 'observe', '1445', { stamp: true }));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log(failed ? `\n自我检查失败:${failed} 项` : '\n全部通过');
|
|
283
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/tail.config.js —— 尾盘选股(EOD Tail Screener)全部可调参数
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 设计原则:
|
|
6
|
+
* 1. 所有阈值 / 权重 / 时段 / 代码段都集中在这里,**改参数不改代码**(FR「配置化」)。
|
|
7
|
+
* 2. 每个参数都写明「口径与调优方向」,方便上线 2–4 周后按复盘结果回调。
|
|
8
|
+
* 3. 本文件是纯数据(无逻辑),可被 CLI 参数 / 环境变量局部覆盖(见 tail_screener.js)。
|
|
9
|
+
*
|
|
10
|
+
* 数据源(2026-09-20 实测确定):
|
|
11
|
+
* - 全市场快照:腾讯 qt.gtimg.cn 批量报价(按代码段枚举,~30000 槽位 / 6 并发 ≈ 8s)
|
|
12
|
+
* - 分时 14:30/14:50:腾讯 web.ifzq.gtimg.cn 分时接口(实测切点与东财 trends2 完全一致)
|
|
13
|
+
* - 行业归属:eod/data/industry-map.json 缓存(由 build_industry_map.js 生成,
|
|
14
|
+
* 东财 clist 为主源;东财对突发高频请求会封 IP,故只用于「低频刷缓存」,不进关键路径)
|
|
15
|
+
* 注:东财 push2*.eastmoney.com 实测在十余次请求后即 socket hang up,**不可作为实时快照源**。
|
|
16
|
+
* ---------------------------------------------------------------------------
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// 代码段:腾讯按段枚举用。不存在的代码在响应里被直接省略,所以可以放心盲扫。
|
|
20
|
+
// 代价参考(实测 6 并发 ≈ 62 请求/秒):沪主板 6000 槽位 ≈ 1.5s、北交所 83xxxx 10000 槽位 ≈ 2.7s。
|
|
21
|
+
const RANGES = [
|
|
22
|
+
{ name: '沪市主板', prefix: 'sh', from: 600000, to: 605999, enabled: true },
|
|
23
|
+
{ name: '科创板', prefix: 'sh', from: 688000, to: 689999, enabled: true },
|
|
24
|
+
{ name: '深市主板', prefix: 'sz', from: 1, to: 3999, enabled: true },
|
|
25
|
+
{ name: '创业板', prefix: 'sz', from: 300000, to: 301999, enabled: true },
|
|
26
|
+
{ name: '北交所430', prefix: 'bj', from: 430000, to: 430999, enabled: true },
|
|
27
|
+
{ name: '北交所83/87', prefix: 'bj', from: 830000, to: 839999, enabled: true },
|
|
28
|
+
{ name: '北交所87', prefix: 'bj', from: 870000, to: 873999, enabled: true },
|
|
29
|
+
{ name: '北交所920', prefix: 'bj', from: 920000, to: 920999, enabled: true },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
version: 1,
|
|
34
|
+
|
|
35
|
+
// =========================================================================
|
|
36
|
+
// 1. 交易时段(FR-01 / Q1:不依赖外部日历,靠行情时间戳 + 本地时钟判断)
|
|
37
|
+
// 实测:腾讯报价第 [30] 字段是行情时间戳 YYYYMMDDHHmmss,
|
|
38
|
+
// 非交易日 / 停牌个股该时间戳不会推进,用它判定「今天到底有没有开盘」最可靠。
|
|
39
|
+
// =========================================================================
|
|
40
|
+
session: {
|
|
41
|
+
// 交易日最早可跑时点。早于此点即使行情可用也不出报告(数据无意义)
|
|
42
|
+
earliest: '09:30',
|
|
43
|
+
// 观察模式起点:≥ 14:30 允许运行(尾盘段起点已出现,可提前预览)
|
|
44
|
+
observeFrom: '14:30',
|
|
45
|
+
// 固定口径时点:≥ 14:50 后运行,一律按 14:50 截断取数。
|
|
46
|
+
// 这样「上午跑一次 + 下午跑一次」得到的是**同一口径**,避免同日结果漂移(Q1 多次运行的前提)。
|
|
47
|
+
cutAt: '14:50',
|
|
48
|
+
// 常规收盘(尾盘口径的分时最后有效点)
|
|
49
|
+
close: '15:00',
|
|
50
|
+
// 超过收盘多少分钟内仍允许按 14:50 口径补跑(收盘后再跑,分时数据已完整)
|
|
51
|
+
graceMinutesAfterClose: 120,
|
|
52
|
+
|
|
53
|
+
// ------- 盘中模式(`--intraday`,显式启用;默认不生效,见下文)-------
|
|
54
|
+
// 意图:尾盘选股的口径天然只能在 14:30 之后成立,但「盘中随手看一眼当前强弱」
|
|
55
|
+
// 是高频需求。故给一个**显式开关**:不传则一切照旧(保留时段守卫,
|
|
56
|
+
// 防止定时任务误触发 / 上午空跑);传了则随时可跑。
|
|
57
|
+
// 口径差异(必须在报告里能一眼看出来,否则会与尾盘口径混为一谈):
|
|
58
|
+
// · cut = 当前已成交的最后一分钟(午休期间夹到 11:30)
|
|
59
|
+
// · 段 = cut 往前推 segMinutes 分钟的**滚动窗口**,不是固定的 14:30 起点
|
|
60
|
+
// 故盘中段的「尾盘动能」含义 = 「最近 N 分钟的动量」,与尾盘的「尾盘 20 分钟」
|
|
61
|
+
// 是两个不同的东西,不可直接与历史正式记录横向比较。
|
|
62
|
+
intraday: {
|
|
63
|
+
enabled: true, // 置 false 可彻底禁用该开关(连 CLI 传参也不理会)
|
|
64
|
+
segMinutes: 20, // 滚动窗口长度(分钟)——与正式口径的 14:30→14:50 等长,便于手感对齐
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// =========================================================================
|
|
69
|
+
// 2. 行情抓取
|
|
70
|
+
// =========================================================================
|
|
71
|
+
market: {
|
|
72
|
+
batch: 60, // 每次批量报价的代码数(实测 60 稳定,过大易被截断)
|
|
73
|
+
concurrency: 6, // 并发;实测 6 并发 ≈ 62 请求/秒,再高易触发腾讯反爬
|
|
74
|
+
retries: 4, // 单批重试次数(命中 WAF 走长退避,见 lib/http.js)
|
|
75
|
+
timeoutMs: 15000,
|
|
76
|
+
ranges: RANGES,
|
|
77
|
+
// 北交所提示:快照抓取正常,但**分时接口对北交所返回的累计量额常为 0**,
|
|
78
|
+
// 导致「均价线强度」算不出来而按 data_gap 剔除(实测 bj920298 即如此)。
|
|
79
|
+
// 加上 30% 涨跌幅制度与流动性差异,若不想在北交所上耗配额,
|
|
80
|
+
// 把 RANGES 里三条「北交所*」的 enabled 改成 false 即可(约省 16000 槽位 / 1.5 秒)。
|
|
81
|
+
excludePrefixes: ['sh900', 'sz200'], // B 股不参与(流动性与口径均不同)
|
|
82
|
+
// 数据健康度红线:有效报价覆盖市场数低于此值即判定「数据源不可用」,中止且不推送空报告
|
|
83
|
+
minValidQuotes: 3000,
|
|
84
|
+
// 报价时间戳落后于「当前交易时点」超过该分钟数 → 判定为陈旧数据(停牌/非交易日)
|
|
85
|
+
staleMinutes: 45,
|
|
86
|
+
// 分时抓取:只对初筛通过的票做(量小),但仍要限速 + 熔断。
|
|
87
|
+
// 实测 2026-09-20:486 只以并发 4 连续打 web.ifzq.gtimg.cn 会触发 WAF(HTTP 501),
|
|
88
|
+
// 且被拦后每只票要重试 19 秒才失败 —— 不熔断就会静默空跑半小时以上。
|
|
89
|
+
// 对策:并发降到 3 + 每次请求后歇 120ms + 失败率超 30% 立即中止;
|
|
90
|
+
// 分时接口另配了 3 台镜像主机(见 lib/market.js 的 MINUTE_ENDPOINTS),单台被拦自动切换。
|
|
91
|
+
minuteConcurrency: 3,
|
|
92
|
+
minuteDelayMs: 120,
|
|
93
|
+
minuteMaxFailRate: 0.3,
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// =========================================================================
|
|
97
|
+
// 3. 初筛过滤(FR-03 / Q5–Q10)—— 尾盘口径 ≠ R01 隔夜口径,独立配置
|
|
98
|
+
// =========================================================================
|
|
99
|
+
filter: {
|
|
100
|
+
// 当日涨幅区间(%)。上限若放到 9% 会引入接近涨停买不到的标的(Q5)
|
|
101
|
+
chgPctMin: 2.0,
|
|
102
|
+
chgPctMax: 7.0,
|
|
103
|
+
// 量比下限(Q6)。14:50 的即时量比天然偏小,1.2 是「已有温和放量」的起点;
|
|
104
|
+
// 想更严可提到 1.5,想放宽可降到 1.0。
|
|
105
|
+
volRatioMin: 1.2,
|
|
106
|
+
// 换手率区间(%)(Q9):过低无人气,过高警惕出货
|
|
107
|
+
turnoverMin: 2.0,
|
|
108
|
+
turnoverMax: 20.0,
|
|
109
|
+
// 流通市值区间(亿)(Q10):与 candidate-pool 现有口径一致,便于横向对照
|
|
110
|
+
floatCapMinYi: 20,
|
|
111
|
+
floatCapMaxYi: 500,
|
|
112
|
+
// 价格下限:低于此价(元)不作为候选(低价股波动噪音大、且多为退市风险股)
|
|
113
|
+
priceMin: 2.0,
|
|
114
|
+
// 相对当日均价的要求(Q8):'hard' 作硬门槛筛掉;'soft' 只扣分不过滤
|
|
115
|
+
aboveAvgMode: 'hard',
|
|
116
|
+
aboveAvgTolerance: 0.0, // 现价相对均价允许的下浮(%,0 = 必须 ≥ 均价)
|
|
117
|
+
// 距日内最高价的回落幅度(%):越大说明越弱
|
|
118
|
+
maxFromHighPct: 1.5,
|
|
119
|
+
// 尾盘段涨幅门槛(%)(Q7):14:30→14:50,尾盘走强的核心特征
|
|
120
|
+
tailSegMinPct: 0.5,
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
// =========================================================================
|
|
124
|
+
// 4. 排除项(不可买 / 噪声过大)
|
|
125
|
+
// =========================================================================
|
|
126
|
+
exclude: {
|
|
127
|
+
st: true, // 名称含 ST / *ST / 退
|
|
128
|
+
suspended: true, // 停牌(成交量 0 或行情时间戳未推进)
|
|
129
|
+
oneWordBoard: true, // 一字板(开=高=低,全天一个价)
|
|
130
|
+
limitUp: true, // 已封涨停(现价 ≈ 最高价 且涨幅到达该板涨停幅度)
|
|
131
|
+
newListing: true, // 新股 / 次新
|
|
132
|
+
newListingDays: 20, // 上市不足 N 个自然日(无上市日期数据时按名称 N/C 前缀兜底)
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
// =========================================================================
|
|
136
|
+
// 5. 强度打分(FR-06 / Q11)—— 总分 100,六项可解释
|
|
137
|
+
// =========================================================================
|
|
138
|
+
score: {
|
|
139
|
+
weights: {
|
|
140
|
+
tailMomentum: 30, // 尾盘动能:14:30→14:50 涨幅线性映射,+2% 封顶
|
|
141
|
+
volume: 20, // 量能:量比 1.2 → 0 分,3.0 → 满分
|
|
142
|
+
position: 15, // 位置:距日内高点越近越高
|
|
143
|
+
avgLine: 10, // 均价线强度:现价 ÷ 均价 的超出幅度
|
|
144
|
+
sector: 15, // 板块强度:所属行业当日涨幅的全市场分位
|
|
145
|
+
turnoverFit: 10, // 换手适中度:3%–10% 满分,向两端递减
|
|
146
|
+
},
|
|
147
|
+
// 尾盘动能:段涨幅达到该值即拿满分(%)
|
|
148
|
+
tailMomentumFullPct: 2.0,
|
|
149
|
+
// 量能:达到该量比即满分
|
|
150
|
+
volRatioFull: 3.0,
|
|
151
|
+
// 均价线:超出均价该百分比即满分(%)
|
|
152
|
+
avgLineFullPct: 1.0,
|
|
153
|
+
// 换手适中区间(%):落在此区间满分
|
|
154
|
+
turnoverSweetLow: 3.0,
|
|
155
|
+
turnoverSweetHigh: 10.0,
|
|
156
|
+
// 板块强度:行业涨幅分位映射的区间(%)。行业中位涨幅达到 hi 即满分
|
|
157
|
+
sectorChgLoPct: 0.0,
|
|
158
|
+
sectorChgHiPct: 2.0,
|
|
159
|
+
// 分时结构加分项(不计入 100 分,单独展示,避免权重膨胀)
|
|
160
|
+
// 段内上涨分钟占比低于该值时在报告中标注「尾盘拉升不连贯」
|
|
161
|
+
tailUpRatioWarn: 0.55,
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// =========================================================================
|
|
165
|
+
// 6. 分组与呈现(FR-07 / Q12)
|
|
166
|
+
// =========================================================================
|
|
167
|
+
group: {
|
|
168
|
+
source: 'industry-map', // industry-map | candidates.json | none
|
|
169
|
+
maxPerGroup: 3, // 每组最多展示几只(同质去重,Q12)
|
|
170
|
+
topN: 10, // 全市场 TOP N
|
|
171
|
+
// 说明:不做「薄组并入其他」的处理。某行业只入选 1 只时,
|
|
172
|
+
// 保留其真实行业名比塞进「其他」更有信息量(能看出资金落在哪个行业)。
|
|
173
|
+
unknownLabel: '未分类',
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// =========================================================================
|
|
177
|
+
// 7. 落库与回放(FR-10 / FR-11 / Q3)
|
|
178
|
+
// =========================================================================
|
|
179
|
+
store: {
|
|
180
|
+
dataDir: 'data', // 相对 eod/ 目录
|
|
181
|
+
reportDir: 'reports',
|
|
182
|
+
// 幂等约定(FR-11):一个交易日一份文件 eod-<交易日>.json,**整文件覆盖**,
|
|
183
|
+
// 文件内 records 按 code 唯一,天然不会出现重复记录。
|
|
184
|
+
// 同日多次运行的历史(几次、各几次候选)记在 runs[] 里留痕。
|
|
185
|
+
// 观察模式(14:30~14:50)另存 eod-<交易日>-obs.json,不污染正式口径;
|
|
186
|
+
// 一天一个文件,多次观察靠 runs[] 留痕(--stamp 才会在文件名里带时点)。
|
|
187
|
+
keepRunHistory: 20, // 每个文件最多保留多少条运行历史
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// =========================================================================
|
|
191
|
+
// 8. 报告(FR-08)
|
|
192
|
+
// =========================================================================
|
|
193
|
+
report: {
|
|
194
|
+
title: '尾盘选股',
|
|
195
|
+
titleEn: 'EOD Tail Screener',
|
|
196
|
+
// 报告中展示的候选上限(分组区块之外再做一次总量控制,避免手机端过长)
|
|
197
|
+
maxRows: 60,
|
|
198
|
+
showScoreBreakdown: true, // Q11:展示分项明细
|
|
199
|
+
showDataGap: true, // 展示被数据缺口剔除的标的(默认折叠)
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
// =========================================================================
|
|
203
|
+
// 9. 通知(FR-09 / Q15 / Q16)
|
|
204
|
+
// =========================================================================
|
|
205
|
+
notify: {
|
|
206
|
+
enabled: true,
|
|
207
|
+
// 通知里 TOP 列表条数
|
|
208
|
+
topInMessage: 5,
|
|
209
|
+
// 失败也必须发一条(Q16),避免「以为跑了其实没跑」
|
|
210
|
+
alertOnFailure: true,
|
|
211
|
+
},
|
|
212
|
+
};
|