candidate-pool 2.1.1 → 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 +91 -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 +83 -26
- package/eod/lib/screen.js +13 -25
- package/eod/lib/store.js +48 -1
- package/eod/lib/trading.js +14 -1
- package/eod/lib/trading.selftest.js +56 -2
- 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 +45 -18
- package/eod/tail_screener.js +55 -8
- 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,13 +77,18 @@ 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
|
}
|
|
82
88
|
const cells = withBreakdown
|
|
83
89
|
? SCORE_COLS.map(([k], idx) => `<td class="${SCORE_CELL_CLS[idx]}">${num(c.score ? c.score[k] : null, 1)}</td>`).join('')
|
|
84
90
|
: '';
|
|
85
|
-
return `<tr data-sector="${esc(c.sector)}">
|
|
91
|
+
return `<tr data-sector="${esc(c.sector)}"${c.bestInGroup ? ' data-best="1"' : ''} data-grank="${c.groupRank || ''}">
|
|
86
92
|
<td>${i + 1}</td>
|
|
87
93
|
<td><b>${esc(c.name)}</b><span class="src">${c.code.replace(/^[a-z]+/, '')}</span></td>
|
|
88
94
|
<td>${b(esc(c.sector), esc(c.sector))}</td>
|
|
@@ -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); }
|
|
@@ -225,12 +233,24 @@ function buildHTML(m) {
|
|
|
225
233
|
const { candidates, groups, top, stats } = result;
|
|
226
234
|
// 三种口径必须在报告顶部一眼可辨:盘中模式的「尾盘段」是滚动窗口近似,
|
|
227
235
|
// 若标签写得像正式结果,复盘时会把近似值当基准(口径混淆)。
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
236
|
+
// 观察模式整点触发时自然窗口退化为 0 分钟 → trading.js 改用最近 observeMinWindow 分钟滚动窗口
|
|
237
|
+
// (segFrom != 1430)。此时必须把「非正式」写进标签,否则复盘会把滚动窗口当 14:30 正式尾盘
|
|
238
|
+
// (口径混淆,与盘中模式同一条红线)。
|
|
239
|
+
let modeLabel;
|
|
240
|
+
if (mode === 'observe' && segFrom !== '1430') {
|
|
241
|
+
const cutMin = (+cutHHMM.slice(0, 2)) * 60 + (+cutHHMM.slice(2, 4));
|
|
242
|
+
const segMin = (+segFrom.slice(0, 2)) * 60 + (+segFrom.slice(2, 4));
|
|
243
|
+
const win = cutMin - segMin;
|
|
244
|
+
modeLabel = b(`观察模式(按最近 ${win} 分钟滚动窗口,与正式口径等长 · 非正式)`,
|
|
245
|
+
`observe mode (rolling ${win}-min window, same length as formal — NOT final)`);
|
|
246
|
+
} else if (mode === 'observe') {
|
|
247
|
+
modeLabel = b(`观察模式(口径 ${cutHHMM},未固定)`, `observe mode (cut ${cutHHMM}, not final)`);
|
|
248
|
+
} else if (mode === 'intraday') {
|
|
249
|
+
modeLabel = b(`盘中模式(口径 ${cutHHMM},尾盘段=最近窗口近似 · 非正式结果)`,
|
|
250
|
+
`intraday mode (cut ${cutHHMM}, rolling-window approximation — NOT final)`);
|
|
251
|
+
} else {
|
|
252
|
+
modeLabel = b(`固定口径 ${cutHHMM}`, `fixed cut ${cutHHMM}`);
|
|
253
|
+
}
|
|
234
254
|
|
|
235
255
|
// ---- 核心结论 ----
|
|
236
256
|
const bestGroup = groups[0];
|
|
@@ -247,7 +267,7 @@ function buildHTML(m) {
|
|
|
247
267
|
`Funnel: snapshot ${stats.snapshotCount} → pre-filter pass ${stats.prePassCount} → after tail checks ${stats.candidateCount}.`),
|
|
248
268
|
];
|
|
249
269
|
|
|
250
|
-
//
|
|
270
|
+
// 两级表头:第一行把五个子分切成「个股自身 77 / 环境与质量 23」两块,
|
|
251
271
|
// 第一行列宽靠 colspan 自动对齐到下面的子分列,不需要额外配 colgroup。
|
|
252
272
|
const topHead = `<tr><th rowspan="2">#</th><th rowspan="2">${b('标的', 'Name')}</th><th rowspan="2">${b('行业', 'Industry')}</th>
|
|
253
273
|
<th rowspan="2">${b('涨幅', 'Chg')}</th><th rowspan="2">${b('尾盘段', 'Tail seg')}</th><th rowspan="2">${b('量比', 'Vol ratio')}</th>
|
|
@@ -259,12 +279,12 @@ function buildHTML(m) {
|
|
|
259
279
|
|
|
260
280
|
// 读分说明:不解释这一步,用户只会看到一堆等宽数字,不知道哪几个该当真。
|
|
261
281
|
const scoreNote = `<p class="score-note" id="scoreNote">${b(
|
|
262
|
-
|
|
263
|
-
`<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>`;
|
|
264
284
|
|
|
265
285
|
// ---- 候选明细(单表双视图):TOP 表与分行业卡片本是同一批候选的两种组织方式,
|
|
266
286
|
// 双列表翻页滚动成本高,故合并为一张表:默认按总分排序,「按行业」视图由前端
|
|
267
|
-
// 把行重排进分组分隔行之下;行业 chips 做筛选;分项
|
|
287
|
+
// 把行重排进分组分隔行之下;行业 chips 做筛选;分项 5 列可用开关收起。
|
|
268
288
|
// 展示上限沿用 report.maxRows(总量控制),列表为 top 之外的全量候选。----
|
|
269
289
|
const listed = candidates.slice(0, cfg.report.maxRows);
|
|
270
290
|
const truncated = candidates.length - listed.length;
|
|
@@ -272,7 +292,15 @@ function buildHTML(m) {
|
|
|
272
292
|
for (const c of listed) { const k = c.sector || '未分类'; chipCounts[k] = (chipCounts[k] || 0) + 1; }
|
|
273
293
|
const chipOrder = groups.map((g) => g.sector).filter((s) => chipCounts[s]);
|
|
274
294
|
for (const k of Object.keys(chipCounts)) if (!chipOrder.includes(k)) chipOrder.push(k);
|
|
295
|
+
// 「只看组内第一」/「组内前三」:跨行业挑更强用的伪过滤值(@best / @top3),
|
|
296
|
+
// 组内排名来自 groupAndRank 的 groupRank(组内按总分降序)。
|
|
297
|
+
const bestCount = listed.reduce((n, c) => n + (c.bestInGroup ? 1 : 0), 0);
|
|
298
|
+
const top3Count = listed.reduce((n, c) => n + ((c.groupRank || 99) <= 3 ? 1 : 0), 0);
|
|
275
299
|
const chipsHtml = [`<button type="button" class="chip active" data-filter="">${b(`全部 ${listed.length}`, `All ${listed.length}`)}</button>`]
|
|
300
|
+
.concat([
|
|
301
|
+
`<button type="button" class="chip" data-filter="@best">${b('只看组内第一', 'Group #1 only')}<i>${bestCount}</i></button>`,
|
|
302
|
+
`<button type="button" class="chip" data-filter="@top3">${b('组内前三', 'Group top 3')}<i>${top3Count}</i></button>`,
|
|
303
|
+
])
|
|
276
304
|
.concat(chipOrder.map((s) =>
|
|
277
305
|
`<button type="button" class="chip" data-filter="${esc(s)}">${b(esc(s), esc(s))}<i>${chipCounts[s]}</i></button>`))
|
|
278
306
|
.join('');
|
|
@@ -420,20 +448,47 @@ function buildHTML(m) {
|
|
|
420
448
|
var viewSectorBtn = document.getElementById('viewSector');
|
|
421
449
|
function renderCand() {
|
|
422
450
|
var frag = document.createDocumentFragment();
|
|
451
|
+
var bestOnly = (candFilter === '@best');
|
|
452
|
+
var top3 = (candFilter === '@top3');
|
|
453
|
+
var ranked = bestOnly || top3; // 过滤视图下 # 列临时改显组内排名
|
|
454
|
+
// 行筛选:@best 只留组内第一;@top3 留组内排名 1-3;其余按行业 chip 过滤
|
|
455
|
+
function want(tr) {
|
|
456
|
+
if (bestOnly) return tr.hasAttribute('data-best');
|
|
457
|
+
if (top3) {
|
|
458
|
+
var g = parseInt(tr.getAttribute('data-grank'), 10);
|
|
459
|
+
return g >= 1 && g <= 3;
|
|
460
|
+
}
|
|
461
|
+
return !candFilter || tr.getAttribute('data-sector') === candFilter;
|
|
462
|
+
}
|
|
463
|
+
var shown = [];
|
|
423
464
|
if (candView === 'score') {
|
|
424
465
|
candRows.forEach(function (tr) {
|
|
425
|
-
if (
|
|
466
|
+
if (want(tr)) { frag.appendChild(tr); shown.push(tr); }
|
|
426
467
|
});
|
|
427
468
|
} else {
|
|
428
469
|
sepOrder.forEach(function (sep) {
|
|
429
470
|
var sec = sep.getAttribute('data-sep');
|
|
430
|
-
if (candFilter && sec !== candFilter) return;
|
|
431
|
-
|
|
471
|
+
if (!bestOnly && !top3 && candFilter && sec !== candFilter) return;
|
|
472
|
+
// 先收集该组本次要显示的行:空组(如组内第一被 maxRows 截断)不渲染分隔行
|
|
473
|
+
var groupRows = [];
|
|
432
474
|
candRows.forEach(function (tr) {
|
|
433
|
-
if (tr.getAttribute('data-sector') === sec)
|
|
475
|
+
if (tr.getAttribute('data-sector') === sec && want(tr)) groupRows.push(tr);
|
|
434
476
|
});
|
|
477
|
+
if (!groupRows.length) return;
|
|
478
|
+
sep.hidden = false; frag.appendChild(sep);
|
|
479
|
+
groupRows.forEach(function (tr) { frag.appendChild(tr); shown.push(tr); });
|
|
435
480
|
});
|
|
436
481
|
}
|
|
482
|
+
// # 列:过滤视图显示组内排名(1/2/3…),切回时恢复原始序号
|
|
483
|
+
shown.forEach(function (tr) {
|
|
484
|
+
var cell = tr.cells[0];
|
|
485
|
+
if (ranked) {
|
|
486
|
+
if (!tr.dataset.idx) tr.dataset.idx = cell.textContent;
|
|
487
|
+
cell.textContent = tr.getAttribute('data-grank') || tr.dataset.idx;
|
|
488
|
+
} else if (tr.dataset.idx) {
|
|
489
|
+
cell.textContent = tr.dataset.idx;
|
|
490
|
+
}
|
|
491
|
+
});
|
|
437
492
|
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
|
438
493
|
tbody.appendChild(frag);
|
|
439
494
|
}
|
|
@@ -452,6 +507,8 @@ function buildHTML(m) {
|
|
|
452
507
|
Array.prototype.forEach.call(document.querySelectorAll('#chips .chip'), function (c) {
|
|
453
508
|
c.classList.toggle('active', (c.getAttribute('data-filter') || '') === candFilter);
|
|
454
509
|
});
|
|
510
|
+
// 组内前三要求同组相邻:选中时自动切到「按行业」视图(用户仍可手动切回按总分)
|
|
511
|
+
if (candFilter === '@top3' && candView !== 'sector') { setView('sector'); return; }
|
|
455
512
|
renderCand();
|
|
456
513
|
});
|
|
457
514
|
var bdToggle = document.getElementById('bdToggle');
|
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
|
};
|