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,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/preopen_rules.js —— 盘前选股规则引擎(纯函数,零副作用,可单测)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 与 rules.js 同范式:所有计算函数入参为数据、出参为结果,不读网络、不改全局。
|
|
6
|
+
* 三类信号:
|
|
7
|
+
* - P01 竞价强势:今开 vs 昨收的高开幅度 + 竞价量能比(竞价量 / 昨日量)
|
|
8
|
+
* - P02 弱转强/超预期:昨日涨幅(强势门槛) + 今日竞价高开区间 → 超预期/符合/不及预期
|
|
9
|
+
* - P03 趋势跟踪:均线多头 + N日新高 + 斜率(来自前一日 K 线)
|
|
10
|
+
*
|
|
11
|
+
* 输入数据约定:
|
|
12
|
+
* - quote:腾讯报价解析对象(market.parseQuote 产出),含 open/prevClose/vol/chgPct 等
|
|
13
|
+
* - kline.rows:前复权日K,【降序】最新在前,每根 { date, open, close, high, low, vol }
|
|
14
|
+
* 锚定日 = 最近已收盘交易日(lastClosedTradingDay),rows[0] 即昨日 bar
|
|
15
|
+
* ---------------------------------------------------------------------------
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
|
|
19
|
+
/** 线性映射 0..1;v 为空返回 0(缺数据不猜值) */
|
|
20
|
+
function lin(v, lo, hi) {
|
|
21
|
+
if (v == null || !Number.isFinite(v)) return 0;
|
|
22
|
+
if (hi === lo) return v >= hi ? 1 : 0;
|
|
23
|
+
return clamp01((v - lo) / (hi - lo));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------- 趋势计算(P03)----------
|
|
27
|
+
/**
|
|
28
|
+
* 由日K(降序)计算趋势指标。
|
|
29
|
+
* @returns {object} { ma5..ma60, maBull, newHigh20, newHigh60, slope20(%/日), volPriceUp }
|
|
30
|
+
*/
|
|
31
|
+
function calcTrend(rowsDesc, cfg) {
|
|
32
|
+
const closes = rowsDesc.map((r) => r.close).reverse(); // 升序:最旧在前
|
|
33
|
+
const n = closes.length;
|
|
34
|
+
const out = { ma5: null, ma10: null, ma20: null, ma60: null, maBull: false, newHigh20: false, newHigh60: false, slope20: null, volPriceUp: false };
|
|
35
|
+
if (n < 5) return out;
|
|
36
|
+
|
|
37
|
+
const ma = (k) => (n >= k ? closes.slice(n - k).reduce((a, b) => a + b, 0) / k : null);
|
|
38
|
+
out.ma5 = ma(5);
|
|
39
|
+
out.ma10 = ma(10);
|
|
40
|
+
out.ma20 = ma(20);
|
|
41
|
+
out.ma60 = ma(60);
|
|
42
|
+
|
|
43
|
+
// 均线多头:MA5>MA10>MA20>MA60(各周期数据齐备才判定)
|
|
44
|
+
if (out.ma5 != null && out.ma10 != null && out.ma20 != null && out.ma60 != null) {
|
|
45
|
+
out.maBull = out.ma5 > out.ma10 && out.ma10 > out.ma20 && out.ma20 > out.ma60;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// N 日新高:昨日收盘为近 N 日最高
|
|
49
|
+
const last20 = closes.slice(n - Math.min(20, n));
|
|
50
|
+
const last60 = closes.slice(n - Math.min(60, n));
|
|
51
|
+
out.newHigh20 = closes[n - 1] >= Math.max(...last20);
|
|
52
|
+
out.newHigh60 = n >= 60 ? closes[n - 1] >= Math.max(...last60) : false;
|
|
53
|
+
|
|
54
|
+
// 斜率:近 20 日收盘价 OLS 回归斜率,折算 %/日(相对均值)
|
|
55
|
+
if (n >= 20) {
|
|
56
|
+
const w = closes.slice(n - 20);
|
|
57
|
+
const x = w.map((_, i) => i);
|
|
58
|
+
const xm = x.reduce((a, b) => a + b, 0) / 20;
|
|
59
|
+
const ym = w.reduce((a, b) => a + b, 0) / 20;
|
|
60
|
+
let num = 0, den = 0;
|
|
61
|
+
for (let i = 0; i < 20; i++) { num += (x[i] - xm) * (w[i] - ym); den += (x[i] - xm) ** 2; }
|
|
62
|
+
const slope = den ? num / den : 0; // 绝对斜率(元/日)
|
|
63
|
+
out.slope20 = ym ? (slope / ym) * 100 : 0; // %/日
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 量价齐升(近 5 日):上涨日占比 ≥ 60%
|
|
67
|
+
if (n >= 6) {
|
|
68
|
+
const recent = rowsDesc.slice(0, 5); // 降序最新 5 日
|
|
69
|
+
const ups = recent.filter((r, i) => i < recent.length - 1 && r.close >= rowsDesc[i + 1].close).length;
|
|
70
|
+
out.volPriceUp = ups / Math.max(1, recent.length - 1) >= 0.6;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- 基线(前一日 K 线指标)----------
|
|
76
|
+
/**
|
|
77
|
+
* 由 K 线计算「前一日」基线:昨日涨幅、昨日成交量、趋势。
|
|
78
|
+
* @param {object} kline { rows: 降序日K, today, hist }(fetchKline 产出)
|
|
79
|
+
* @returns {object|null} { yesterdayChgPct, yesterdayVol, trend }
|
|
80
|
+
*/
|
|
81
|
+
function calcBaseline(kline, code, cfg) {
|
|
82
|
+
const rows = (kline && kline.rows) || [];
|
|
83
|
+
if (!rows.length) return null;
|
|
84
|
+
const y = rows[0]; // 昨日 bar
|
|
85
|
+
const prior = rows[1]; // 前日 bar
|
|
86
|
+
let yesterdayChgPct = null;
|
|
87
|
+
if (y && prior && prior.close > 0) {
|
|
88
|
+
yesterdayChgPct = (y.close / prior.close - 1) * 100;
|
|
89
|
+
}
|
|
90
|
+
const yesterdayVol = y ? y.vol : null; // 手(与竞价量同口径)
|
|
91
|
+
const trend = calcTrend(rows, cfg);
|
|
92
|
+
return { code, yesterdayChgPct, yesterdayVol, trend };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------- P01 竞价强势 ----------
|
|
96
|
+
function calcP1(quote, baseline, cfg) {
|
|
97
|
+
const p = cfg.rules.p1;
|
|
98
|
+
const prevClose = quote.prevClose;
|
|
99
|
+
const open = quote.open;
|
|
100
|
+
let auctionChgPct = null;
|
|
101
|
+
if (prevClose > 0 && open != null && open > 0) {
|
|
102
|
+
auctionChgPct = (open / prevClose - 1) * 100;
|
|
103
|
+
}
|
|
104
|
+
// 竞价量能比 = 竞价成交量 / 昨日全天成交量(同口径,免单位换算)
|
|
105
|
+
let auctionVolRatio = null;
|
|
106
|
+
const yVol = baseline && baseline.yesterdayVol;
|
|
107
|
+
if (quote.vol != null && yVol) auctionVolRatio = quote.vol / yVol;
|
|
108
|
+
|
|
109
|
+
const highOpen = auctionChgPct != null && auctionChgPct >= p.auctionChgMin;
|
|
110
|
+
const nearLimit = auctionChgPct != null && auctionChgPct >= p.auctionChgLimit;
|
|
111
|
+
const volOk = auctionVolRatio != null && auctionVolRatio >= p.auctionVolRatioMin;
|
|
112
|
+
|
|
113
|
+
// 打分:高开幅度(0.6) + 量能比(0.4);未高开则 0 分
|
|
114
|
+
const sChg = lin(auctionChgPct, p.auctionChgMin, p.auctionChgLimit);
|
|
115
|
+
const sVol = lin(auctionVolRatio, p.auctionVolRatioMin, p.auctionVolRatioFull);
|
|
116
|
+
const score = highOpen ? 0.6 * sChg + 0.4 * sVol : 0;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
auctionChgPct, auctionVolRatio, highOpen, nearLimit, volOk,
|
|
120
|
+
score, ok: highOpen && volOk,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- P02 弱转强 / 超预期 ----------
|
|
125
|
+
function calcP2(quote, baseline, cfg) {
|
|
126
|
+
const p = cfg.rules.p2;
|
|
127
|
+
const yChg = baseline ? baseline.yesterdayChgPct : null;
|
|
128
|
+
// 竞价涨幅复用 P1 的计算(调用方传入,避免重复)
|
|
129
|
+
const aChg = quote.__auctionChgPct != null ? quote.__auctionChgPct : null;
|
|
130
|
+
const applicable = yChg != null && yChg >= p.yesterdayStrong;
|
|
131
|
+
|
|
132
|
+
let verdict = '非强势股';
|
|
133
|
+
let score = 0;
|
|
134
|
+
let risk = false;
|
|
135
|
+
if (applicable && aChg != null) {
|
|
136
|
+
if (aChg >= p.exceed) {
|
|
137
|
+
verdict = aChg >= p.yesterdayLimit ? '超预期·涨停预期' : '超预期·高开';
|
|
138
|
+
score = 1.0;
|
|
139
|
+
} else if (aChg >= p.expectLow) {
|
|
140
|
+
verdict = '符合预期·健康高开';
|
|
141
|
+
score = 0.7;
|
|
142
|
+
} else if (aChg >= 0) {
|
|
143
|
+
verdict = '偏弱高开';
|
|
144
|
+
score = 0.3;
|
|
145
|
+
} else {
|
|
146
|
+
verdict = '不及预期·核按钮';
|
|
147
|
+
score = 0.0;
|
|
148
|
+
risk = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { yesterdayChgPct: yChg, auctionChgPct: aChg, applicable, verdict, score, risk };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------- P03 趋势跟踪 ----------
|
|
155
|
+
function calcP3(baseline, cfg) {
|
|
156
|
+
const p = cfg.rules.p3;
|
|
157
|
+
const t = (baseline && baseline.trend) || {};
|
|
158
|
+
const slope = t.slope20;
|
|
159
|
+
const sSlope = lin(slope, p.slopeMinPerDay, p.slopeFullPerDay);
|
|
160
|
+
const sHigh = (t.newHigh20 ? 1 : 0) * 0.3 + (t.newHigh60 ? 1 : 0) * 0.2;
|
|
161
|
+
// 权重:多头 0.4 + 新高 0.3(20日0.3+60日0.2取含) + 斜率 0.3
|
|
162
|
+
const score = (t.maBull ? 0.4 : 0) + Math.min(0.3, sHigh) + 0.3 * sSlope;
|
|
163
|
+
return { ...t, score: clamp01(score), ok: !!t.maBull };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------- 综合打分 ----------
|
|
167
|
+
function scorePreopen(p1, p2, p3, cfg) {
|
|
168
|
+
const w = cfg.score.weights;
|
|
169
|
+
const items = { p1: p1.score, p2: p2.score, p3: p3.score };
|
|
170
|
+
const total = w.p1 * items.p1 + w.p2 * items.p2 + w.p3 * items.p3;
|
|
171
|
+
return { total, items };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 由三项结果推导一句话标签(报告/通知用) */
|
|
175
|
+
function labelOf(p1, p2, p3) {
|
|
176
|
+
if (p2.verdict && p2.verdict.startsWith('超预期')) return '超预期';
|
|
177
|
+
if (p2.verdict === '符合预期·健康高开') return '弱转强·符合';
|
|
178
|
+
if (p2.verdict === '不及预期·核按钮') return '不及预期·风险';
|
|
179
|
+
if (p1.nearLimit) return '竞价涨停';
|
|
180
|
+
if (p1.highOpen && p3.ok) return '竞价强+趋势';
|
|
181
|
+
if (p1.highOpen) return '竞价强势';
|
|
182
|
+
if (p3.ok) return '趋势向上';
|
|
183
|
+
return '观察';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
clamp01, lin,
|
|
188
|
+
calcTrend, calcBaseline,
|
|
189
|
+
calcP1, calcP2, calcP3, scorePreopen, labelOf,
|
|
190
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/preopen_store.js —— 盘前选股落库(JSON 归档,溯源用)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 一天一份文件:preopen/data/preopen-YYYYMMDD.json
|
|
6
|
+
* - records: code -> 候选明细(含 P1/P2/P3 分项、基线、标签、总分)
|
|
7
|
+
* - runs[]: 当日多次运行留痕(幂等:同文件整文件覆盖,records 按 code 唯一)
|
|
8
|
+
* - close:收盘回填后追加当日收盘/涨跌幅/是否涨停/命中标记(preopen_backfill.js)
|
|
9
|
+
*
|
|
10
|
+
* 与 tail store 解耦:盘前数据既要「开盘快照」又要「收盘回填」,结构不同,独立维护。
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const paths = require('../../paths');
|
|
16
|
+
|
|
17
|
+
function dataFile(cfg, tradeDate) {
|
|
18
|
+
return path.join(paths.eodDir(), cfg.store.dataDir, `${cfg.store.prefix}-${tradeDate}.json`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 读已有归档(无则返回 null) */
|
|
22
|
+
function loadRun(cfg, tradeDate) {
|
|
23
|
+
const f = dataFile(cfg, tradeDate);
|
|
24
|
+
if (!fs.existsSync(f)) return null;
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 落库(幂等:同文件整文件覆盖,runs[] 追加本次运行)。
|
|
30
|
+
* @param {object} p { cfg, tradeDate, scope, candidates, meta, marketDate }
|
|
31
|
+
* @returns {{file, recordCount, runCount}}
|
|
32
|
+
*/
|
|
33
|
+
function saveRun({ cfg, tradeDate, scope, candidates, meta, marketDate }) {
|
|
34
|
+
const dir = path.join(paths.eodDir(), cfg.store.dataDir);
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
const f = dataFile(cfg, tradeDate);
|
|
37
|
+
|
|
38
|
+
const prev = loadRun(cfg, tradeDate) || {};
|
|
39
|
+
const records = prev.records || {};
|
|
40
|
+
for (const c of candidates) records[c.code] = c;
|
|
41
|
+
|
|
42
|
+
const runs = prev.runs || [];
|
|
43
|
+
runs.push({
|
|
44
|
+
at: new Date().toISOString(),
|
|
45
|
+
count: candidates.length,
|
|
46
|
+
codes: candidates.map((c) => c.code),
|
|
47
|
+
});
|
|
48
|
+
if (runs.length > cfg.store.keepRunHistory) runs.splice(0, runs.length - cfg.store.keepRunHistory);
|
|
49
|
+
|
|
50
|
+
const doc = {
|
|
51
|
+
tradeDate,
|
|
52
|
+
scope: scope || cfg.scope.default,
|
|
53
|
+
mode: 'preopen',
|
|
54
|
+
generatedAt: new Date().toISOString(),
|
|
55
|
+
marketDate,
|
|
56
|
+
meta: Object.assign({}, meta, { recordCount: Object.keys(records).length, runCount: runs.length }),
|
|
57
|
+
runs,
|
|
58
|
+
// 保留历史回填结果(若有)
|
|
59
|
+
close: prev.close || null,
|
|
60
|
+
records,
|
|
61
|
+
};
|
|
62
|
+
fs.writeFileSync(f, JSON.stringify(doc, null, 2), 'utf8');
|
|
63
|
+
return { file: f, recordCount: doc.meta.recordCount, runCount: runs.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 收盘回填:把当日收盘数据合并进归档(不重建候选,只追加 close 段)。
|
|
68
|
+
* @param {object} closeMap code -> { close, dayChgPct, limitUp, hit }
|
|
69
|
+
*/
|
|
70
|
+
function mergeBackfill(cfg, tradeDate, closeMap, summary) {
|
|
71
|
+
const doc = loadRun(cfg, tradeDate);
|
|
72
|
+
if (!doc) throw new Error(`未找到盘前归档 ${tradeDate},无法回填`);
|
|
73
|
+
doc.close = {
|
|
74
|
+
backfilledAt: new Date().toISOString(),
|
|
75
|
+
marketDate: tradeDate,
|
|
76
|
+
hitDayChgMin: summary.hitDayChgMin,
|
|
77
|
+
total: 0, hit: 0,
|
|
78
|
+
perCode: {},
|
|
79
|
+
};
|
|
80
|
+
let hit = 0, total = 0;
|
|
81
|
+
for (const [code, v] of Object.entries(closeMap)) {
|
|
82
|
+
const rec = doc.records[code];
|
|
83
|
+
if (!rec) continue;
|
|
84
|
+
rec.close = v;
|
|
85
|
+
total++;
|
|
86
|
+
if (v.hit) hit++;
|
|
87
|
+
doc.close.perCode[code] = v;
|
|
88
|
+
}
|
|
89
|
+
doc.close.total = total;
|
|
90
|
+
doc.close.hit = hit;
|
|
91
|
+
doc.close.hitRate = total ? +(hit / total * 100).toFixed(1) : 0;
|
|
92
|
+
fs.writeFileSync(dataFile(cfg, tradeDate), JSON.stringify(doc, null, 2), 'utf8');
|
|
93
|
+
return doc.close;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { dataFile, loadRun, saveRun, mergeBackfill };
|
package/eod/lib/report.js
CHANGED
|
@@ -23,17 +23,19 @@ function yi(n) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(1) +
|
|
|
23
23
|
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
24
24
|
|
|
25
25
|
/*
|
|
26
|
-
*
|
|
27
|
-
* · 个股自身
|
|
28
|
-
* · 环境与质量
|
|
29
|
-
*
|
|
26
|
+
* 五个分项按「这分是谁给的」分两组(2026-10-22 回溯调参后):
|
|
27
|
+
* · 个股自身 77 分 = 尾盘动能 35 + 量能 24 + 位置 18 —— 这只票「自己在走」的证据,**重点看这组**;
|
|
28
|
+
* · 环境与质量 23 分 = 均价线 12 + 换手 11 —— 流动性与位置给的背景分。
|
|
29
|
+
* 原「板块强度 15 分」因在全部候选上恒为满分(常量、零区分度)被删除,
|
|
30
|
+
* 权重按原比例重分配(30/20/15/10/10 → 35/24/18/12/11),依据见 docs/backtest/01-复盘.md。
|
|
31
|
+
* 只看总分会被背景分抬高分数骗到,故两组在表头/底色上必须可分辨。
|
|
30
32
|
* 组内按权重降序(权重大的先看)—— 顺序即渲染顺序。
|
|
31
33
|
* ⚠️ 与 web/src/views/TailView.vue 的 SCORE_GROUPS 是同一份口径,改一处必须改另一处。
|
|
32
34
|
*/
|
|
33
35
|
const SCORE_GROUPS = [
|
|
34
36
|
{
|
|
35
|
-
key: 'self', cls: 'self', max:
|
|
36
|
-
zh: '
|
|
37
|
+
key: 'self', cls: 'self', max: 77,
|
|
38
|
+
zh: '个股自身(77 分)', en: 'The stock itself (77)',
|
|
37
39
|
cols: [
|
|
38
40
|
['tailMomentum', '尾盘动能', 'Tail momentum'],
|
|
39
41
|
['volume', '量能', 'Volume'],
|
|
@@ -41,10 +43,9 @@ const SCORE_GROUPS = [
|
|
|
41
43
|
],
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
|
-
key: 'ctx', cls: 'ctx', max:
|
|
45
|
-
zh: '
|
|
46
|
+
key: 'ctx', cls: 'ctx', max: 23,
|
|
47
|
+
zh: '环境质量(23 分)', en: 'Context & quality (23)',
|
|
46
48
|
cols: [
|
|
47
|
-
['sector', '板块', 'Sector'],
|
|
48
49
|
['avgLine', '均价线', 'VWAP'],
|
|
49
50
|
['turnoverFit', '换手', 'Turnover'],
|
|
50
51
|
],
|
|
@@ -76,6 +77,11 @@ function rowHtml(c, i, cfg, withBreakdown) {
|
|
|
76
77
|
const warn = cfg.score.tailUpRatioWarn;
|
|
77
78
|
const tagBits = [];
|
|
78
79
|
if (c.bestInGroup) tagBits.push(`<span class="tag pass">${b('组内最优', 'best in group')}</span>`);
|
|
80
|
+
// 2026-10-22 回溯调参:涨幅是全部原始特征里与次日收益相关最强的(ρ=+0.150),
|
|
81
|
+
// 5–7% 段是唯一正期望的涨幅桶,故对 chg≥5% 的候选加显式标记(决策清单第 8 项)。
|
|
82
|
+
if (c.chgPct != null && c.chgPct >= 5) {
|
|
83
|
+
tagBits.push(`<span class="tag hot">${b('涨幅≥5%', 'chg ≥5%')}</span>`);
|
|
84
|
+
}
|
|
79
85
|
if (tail.upRatio != null && tail.upRatio < warn) {
|
|
80
86
|
tagBits.push(`<span class="tag gap">${b('拉升不连贯', 'choppy tail')}</span>`);
|
|
81
87
|
}
|
|
@@ -161,9 +167,10 @@ const CSS = ` :root, :root[data-theme="light"] {
|
|
|
161
167
|
.tag.fail { background: #f1f5f9; color: var(--slate); }
|
|
162
168
|
.tag.pass { background: #fef2f2; color: var(--red); }
|
|
163
169
|
.tag.gap { background: #fffbeb; color: var(--amber); }
|
|
170
|
+
.tag.hot { background: var(--red); color: #fff; font-weight: 700; }
|
|
164
171
|
.table-wrap { overflow-x: auto; }
|
|
165
172
|
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
166
|
-
th, td { padding: 8px 9px; text-align:
|
|
173
|
+
th, td { padding: 8px 9px; text-align: center; vertical-align: middle; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
167
174
|
th { color: var(--muted); font-weight: 600; background: #fefce8; }
|
|
168
175
|
/* 六维分项分两组:表头两级 + 组底色 + 组首左分隔线,让「重点看哪组」不用读文字 */
|
|
169
176
|
th.grp { text-align: center; font-size: 0.8rem; border-bottom: 1px solid var(--border); }
|
|
@@ -172,7 +179,7 @@ const CSS = ` :root, :root[data-theme="light"] {
|
|
|
172
179
|
th.grp-sub.grp-self { background: #fff7ed; color: var(--accent); }
|
|
173
180
|
th.grp-sub.grp-ctx { background: #f1f5f9; color: var(--slate); }
|
|
174
181
|
th.sc-start, td.sc-start { border-left: 2px solid var(--border); }
|
|
175
|
-
td.num { text-align:
|
|
182
|
+
td.num { text-align: center; font-variant-numeric: tabular-nums; }
|
|
176
183
|
td.sc-self { background: rgba(217, 119, 6, 0.06); }
|
|
177
184
|
td.sc-ctx { background: rgba(100, 116, 139, 0.05); }
|
|
178
185
|
.score-note { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.75; color: #57534e; background: #fff7ed; border: 1px dashed var(--border); border-radius: 8px; padding: 10px 14px; }
|
|
@@ -192,6 +199,7 @@ const CSS = ` :root, :root[data-theme="light"] {
|
|
|
192
199
|
:root[data-theme="dark"] .tag.fail { background: var(--tag-fail-bg); }
|
|
193
200
|
:root[data-theme="dark"] .tag.pass { background: var(--tag-pass-bg); }
|
|
194
201
|
:root[data-theme="dark"] .tag.gap { background: var(--tag-gap-bg); }
|
|
202
|
+
:root[data-theme="dark"] .tag.hot { background: #b91c1c; color: #fff; }
|
|
195
203
|
:root[data-theme="dark"] th { background: var(--th-bg); }
|
|
196
204
|
:root[data-theme="dark"] th.grp-self { background: var(--grp-self-bg); color: var(--grp-self-fg); }
|
|
197
205
|
:root[data-theme="dark"] th.grp-ctx { background: var(--grp-ctx-bg); color: var(--grp-ctx-fg); }
|
|
@@ -259,7 +267,7 @@ function buildHTML(m) {
|
|
|
259
267
|
`Funnel: snapshot ${stats.snapshotCount} → pre-filter pass ${stats.prePassCount} → after tail checks ${stats.candidateCount}.`),
|
|
260
268
|
];
|
|
261
269
|
|
|
262
|
-
//
|
|
270
|
+
// 两级表头:第一行把五个子分切成「个股自身 77 / 环境与质量 23」两块,
|
|
263
271
|
// 第一行列宽靠 colspan 自动对齐到下面的子分列,不需要额外配 colgroup。
|
|
264
272
|
const topHead = `<tr><th rowspan="2">#</th><th rowspan="2">${b('标的', 'Name')}</th><th rowspan="2">${b('行业', 'Industry')}</th>
|
|
265
273
|
<th rowspan="2">${b('涨幅', 'Chg')}</th><th rowspan="2">${b('尾盘段', 'Tail seg')}</th><th rowspan="2">${b('量比', 'Vol ratio')}</th>
|
|
@@ -271,12 +279,12 @@ function buildHTML(m) {
|
|
|
271
279
|
|
|
272
280
|
// 读分说明:不解释这一步,用户只会看到一堆等宽数字,不知道哪几个该当真。
|
|
273
281
|
const scoreNote = `<p class="score-note" id="scoreNote">${b(
|
|
274
|
-
|
|
275
|
-
`<b class='sn-self'>The stock itself —
|
|
282
|
+
`五个分项按「这分是谁给的」分成两组:<b class='sn-self'>个股自身 77 分</b>(尾盘动能 35 + 量能 24 + 位置 18)—— 这只票自己在走出来的证据,<b>重点看这一组</b>;<b class='sn-ctx'>环境与质量 23 分</b>(均价线 12 + 换手 11)—— 流动性与位置给的背景分,靠它撑起来的高分不可信。总分与次日收益相关性弱(ρ≈0.04),<b>「涨幅≥5%」标记比总分更值得看</b>(涨幅是相关最强的特征,ρ=+0.15)。`,
|
|
283
|
+
`<b class='sn-self'>The stock itself — 77 pts</b> (tail momentum 35 + volume 24 + position 18): evidence this name moved on its own — <b>this is the group to read first</b>. <b class='sn-ctx'>Context & quality — 23 pts</b> (VWAP 12 + turnover 11): background handed over by liquidity and position; a high total carried by this group alone is not trustworthy. The total correlates only weakly with next-day return (ρ≈0.04), so the <b>chg ≥5% flag</b> deserves more attention than the total (chg is the strongest correlate, ρ=+0.15).`)}</p>`;
|
|
276
284
|
|
|
277
285
|
// ---- 候选明细(单表双视图):TOP 表与分行业卡片本是同一批候选的两种组织方式,
|
|
278
286
|
// 双列表翻页滚动成本高,故合并为一张表:默认按总分排序,「按行业」视图由前端
|
|
279
|
-
// 把行重排进分组分隔行之下;行业 chips 做筛选;分项
|
|
287
|
+
// 把行重排进分组分隔行之下;行业 chips 做筛选;分项 5 列可用开关收起。
|
|
280
288
|
// 展示上限沿用 report.maxRows(总量控制),列表为 top 之外的全量候选。----
|
|
281
289
|
const listed = candidates.slice(0, cfg.report.maxRows);
|
|
282
290
|
const truncated = candidates.length - listed.length;
|
package/eod/lib/screen.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* (已退市个股仍会返回陈旧价格,只看价格会把它们放进来)
|
|
12
12
|
* - 一字板:开=高=低(全天一个价,实盘买不到)
|
|
13
13
|
* - 已封涨停:现价达到该板块涨停价(主板 10% / 双创 20% / 北交所 30%)
|
|
14
|
-
* 3. 打分(FR-06
|
|
14
|
+
* 3. 打分(FR-06)五项全部线性映射到 0..1 再加权,**每一项都能在报告里解释**,
|
|
15
15
|
* 不做「黑箱总分」。任何一项缺数据记 0 分而不是猜值。
|
|
16
|
+
* (2026-10-22 回溯调参:原第六项「板块强度」在全部候选上恒为满分(常量,
|
|
17
|
+
* 零区分度)已删除,权重按比例重分配,见 tail.config.js score 段注释。)
|
|
16
18
|
* 4. 分组(FR-07)按行业聚合,组内按总分降序,标记组内最优。
|
|
17
19
|
* ---------------------------------------------------------------------------
|
|
18
20
|
*/
|
|
@@ -36,6 +38,7 @@ const REASON = {
|
|
|
36
38
|
TAIL_SEG: '尾盘段涨幅不足',
|
|
37
39
|
BELOW_AVG: '现价低于当日均价',
|
|
38
40
|
FROM_HIGH: '距日内高点回落过多',
|
|
41
|
+
LOW_SCORE: '总分低于下限',
|
|
39
42
|
};
|
|
40
43
|
|
|
41
44
|
/** 板块判定:涨跌幅制度与成交量单位都与板块相关 */
|
|
@@ -169,26 +172,6 @@ function turnoverFit(t, s, f) {
|
|
|
169
172
|
return clamp01((f.turnoverMax - t) / (f.turnoverMax - s.turnoverSweetHigh));
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
/**
|
|
173
|
-
* 板块强度(实测后定的混合口径)。
|
|
174
|
-
*
|
|
175
|
-
* 只用「行业涨幅中位数的绝对阈值」会在普涨日整体饱和——实测 2026-09-18
|
|
176
|
-
* 全市场 68.7% 个股上涨,多数行业中位数都超过 2%,导致候选清一色拿满分、该项失去区分度。
|
|
177
|
-
* 只用「分位」则会在普跌日把「跌得最少的行业」也抬到满分,同样失真。
|
|
178
|
-
* 故取两者折中:
|
|
179
|
-
* 1. 行业中位数 ≤ sectorChgLoPct(默认 0%)→ 0 分:行业实际在跌,不给板块加分;
|
|
180
|
-
* 2. 行业中位数 ≥ sectorChgHiPct(默认 2%)→ 满分:绝对强度已足够;
|
|
181
|
-
* 3. 区间内 → 按该行业在全市场行业涨幅中的**分位**给分(相对强弱,自归一)。
|
|
182
|
-
*/
|
|
183
|
-
function sectorStrength(sec, s) {
|
|
184
|
-
if (!sec || sec.median == null) return 0;
|
|
185
|
-
if (sec.median <= s.sectorChgLoPct) return 0;
|
|
186
|
-
if (sec.median >= s.sectorChgHiPct) return 1;
|
|
187
|
-
if (!sec.total || sec.total <= 1) return 0;
|
|
188
|
-
const pct = (sec.total - sec.rank) / (sec.total - 1); // rank=1 → 1,最末 → 0
|
|
189
|
-
return clamp01(pct);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
175
|
/** 「距日内高点」百分比:越小越强 */
|
|
193
176
|
function fromHighPct(q, tm) {
|
|
194
177
|
if (!(q.high > 0) || tm == null || tm.p1 == null) return null;
|
|
@@ -196,7 +179,7 @@ function fromHighPct(q, tm) {
|
|
|
196
179
|
}
|
|
197
180
|
|
|
198
181
|
/**
|
|
199
|
-
*
|
|
182
|
+
* 五项打分(FR-06)。总分 = Σ(权重 × 归一化值)。
|
|
200
183
|
* @returns {{total:number, items:object, ctx:object}} items 为各项得分(已乘权重)
|
|
201
184
|
*/
|
|
202
185
|
function scoreStock(q, tm, cfg, sectorStats, sector) {
|
|
@@ -216,8 +199,6 @@ function scoreStock(q, tm, cfg, sectorStats, sector) {
|
|
|
216
199
|
position: w.position * (1 - lin(fh, 0, f.maxFromHighPct)),
|
|
217
200
|
// 均价线强度:高出均价 0% → 0 分,avgLineFullPct → 满分
|
|
218
201
|
avgLine: w.avgLine * lin(tm.priceVsAvgPct, 0, s.avgLineFullPct),
|
|
219
|
-
// 板块强度:见 sectorStrength() 的口径说明
|
|
220
|
-
sector: w.sector * sectorStrength(sec, s),
|
|
221
202
|
// 换手适中度
|
|
222
203
|
turnoverFit: w.turnoverFit * turnoverFit(q.turnover, s, f),
|
|
223
204
|
};
|
|
@@ -295,6 +276,13 @@ function buildCandidates({ quotes, minutes, cfg, map, tradeDate, cutHHMM, segFro
|
|
|
295
276
|
|
|
296
277
|
const sector = sectorOf(map, q.code) || UNKNOWN;
|
|
297
278
|
const sc = scoreStock(q, tm, cfg, sectorStats, sector);
|
|
279
|
+
// 总分下限(2026-10-22 回溯调参新增):45–55 段是回溯里最差的一段,
|
|
280
|
+
// 打不过线的连同原因进 dropped,报告「数据缺口」区可见。
|
|
281
|
+
if (cfg.score.minTotal != null && sc.total < cfg.score.minTotal) {
|
|
282
|
+
tailReasons[REASON.LOW_SCORE] = (tailReasons[REASON.LOW_SCORE] || 0) + 1;
|
|
283
|
+
dropped.push({ code: q.code, name: q.name, reason: REASON.LOW_SCORE });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
298
286
|
const sec = sectorStats.get(sector);
|
|
299
287
|
|
|
300
288
|
candidates.push({
|
|
@@ -351,5 +339,5 @@ function buildCandidates({ quotes, minutes, cfg, map, tradeDate, cutHHMM, segFro
|
|
|
351
339
|
module.exports = {
|
|
352
340
|
REASON, BOARD_LABEL, boardOf, limitUpPct, daysBetween,
|
|
353
341
|
preFilterOne, preFilter, postCheck, scoreStock, turnoverFit, fromHighPct, lin,
|
|
354
|
-
|
|
342
|
+
turnoverFit, groupAndRank, buildCandidates,
|
|
355
343
|
};
|
|
@@ -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
|
+
};
|