candidate-pool 2.2.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/eod/data/preopen-2026-10-22.json +1818 -0
- package/eod/lib/market.js +50 -0
- package/eod/lib/preopen_report.js +418 -0
- package/eod/lib/preopen_rules.js +190 -0
- package/eod/lib/preopen_store.js +105 -0
- package/eod/lib/report.js +23 -15
- package/eod/lib/screen.js +13 -25
- package/eod/preopen.config.js +185 -0
- package/eod/preopen_backfill.js +87 -0
- package/eod/preopen_screener.js +375 -0
- package/eod/tail.config.js +46 -19
- package/gen_candidates.js +5 -1
- package/package.json +5 -1
package/eod/lib/market.js
CHANGED
|
@@ -339,7 +339,57 @@ async function fetchMinutes(codes, cfg, log = _log) {
|
|
|
339
339
|
return res;
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// 日K线(前复权 qfq)—— 抽自 gen_candidates.js,作为行情层公共能力,供盘前基线等复用。
|
|
344
|
+
// 腾讯按日期【升序】返回,统一翻转为【降序】(最新在前),与下游 hist[0]=前一交易日一致。
|
|
345
|
+
// 三主机降级链(与分时同理:某台被 WAF 封则切下一台,粘性记忆)。
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
const KLINE_ENDPOINTS = [
|
|
348
|
+
{ name: 'web.ifzq/fqkline', qfq: true, url: (c) => `https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
349
|
+
{ name: 'ifzq/fqkline', qfq: true, url: (c) => `https://ifzq.gtimg.cn/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
350
|
+
{ name: 'proxy.finance/fqkline', qfq: true, url: (c) => `https://proxy.finance.qq.com/ifzqgtimg/appstock/app/fqkline/get?param=${c},day,,,260,qfq` },
|
|
351
|
+
{ name: 'web.ifzq/kline(不复权)', qfq: false, url: (c) => `https://web.ifzq.gtimg.cn/appstock/app/kline/kline?param=${c},day,,,260` },
|
|
352
|
+
];
|
|
353
|
+
let _klineEp = 0;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 抓单只日K(前复权),返回 { rows, today, hist }。
|
|
357
|
+
* @param {string} code sh/sz/bj + 6 位
|
|
358
|
+
* @param {string} anchor 'YYYY-MM-DD' 锚定日(取至此日的 K 线)
|
|
359
|
+
*/
|
|
360
|
+
async function fetchKline(code, anchor) {
|
|
361
|
+
let lastErr;
|
|
362
|
+
for (let step = 0; step < KLINE_ENDPOINTS.length; step++) {
|
|
363
|
+
const idx = (_klineEp + step) % KLINE_ENDPOINTS.length;
|
|
364
|
+
const ep = KLINE_ENDPOINTS[idx];
|
|
365
|
+
let obj;
|
|
366
|
+
try {
|
|
367
|
+
obj = await httpGet(ep.url(code), { json: true, retries: 2, timeout: 15000 });
|
|
368
|
+
} catch (e) {
|
|
369
|
+
lastErr = e;
|
|
370
|
+
if (e.waf || /timeout|ECONN|socket|HTTP \d/i.test(String(e.message || ''))) continue;
|
|
371
|
+
throw e;
|
|
372
|
+
}
|
|
373
|
+
const node = (obj.data && obj.data[code]) || {};
|
|
374
|
+
const rows = node.qfqday || node.day || [];
|
|
375
|
+
if (!rows.length) { lastErr = new Error('K线为空'); continue; }
|
|
376
|
+
if (idx !== _klineEp) {
|
|
377
|
+
_klineEp = idx;
|
|
378
|
+
_log(`[降级] K线数据源切换为 ${ep.name}${ep.qfq ? '' : '(不复权,除权日附近数据会失真)'}`);
|
|
379
|
+
}
|
|
380
|
+
const norm = rows.map((r) => ({ date: r[0], open: +r[1], close: +r[2], high: +r[3], low: +r[4], vol: +r[5] }))
|
|
381
|
+
.filter((r) => r.date && !isNaN(r.close))
|
|
382
|
+
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); // 降序:最新在前
|
|
383
|
+
const till = norm.filter((r) => r.date <= anchor);
|
|
384
|
+
const today = till.find((r) => r.date === anchor) || null;
|
|
385
|
+
const hist = till.filter((r) => r.date < anchor && r.vol > 0);
|
|
386
|
+
return { rows: till, today, hist, source: ep.name, qfq: ep.qfq };
|
|
387
|
+
}
|
|
388
|
+
throw lastErr || new Error('全部 K线端点均不可用');
|
|
389
|
+
}
|
|
390
|
+
|
|
342
391
|
module.exports = {
|
|
343
392
|
volLotSize, expandCodes, parseQuote, fetchBatch, fullMarketSnapshot,
|
|
344
393
|
fetchMinute, fetchMinutes, tailMetrics, barAt, barAtOrBefore, setLog,
|
|
394
|
+
fetchKline, KLINE_ENDPOINTS,
|
|
345
395
|
};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/preopen_report.js —— 盘前选股 HTML 报告(自包含,内联 CSS)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 视觉与交互对齐 eod/lib/report.js(尾盘富报告):主题切换(深浅色+localStorage)、
|
|
6
|
+
* 中英双语、按总分/按行业双视图、标签&行业双重过滤、分项列开关、两级分组表头。
|
|
7
|
+
* 涨红跌绿(A 股口径):.up=红, .down=绿。零依赖,单文件 HTML。
|
|
8
|
+
*
|
|
9
|
+
* 结构:
|
|
10
|
+
* topbar(标题+主题+语言) → meta-bar → 核心结论
|
|
11
|
+
* → 候选明细(工具栏+双视图+过滤) → 分组概览 → 数据缺口(折叠)
|
|
12
|
+
* → 数据口径与偏差 → 免责声明
|
|
13
|
+
*
|
|
14
|
+
* 列:# / 名称+代码 / 行业 / 板块 / 今开% / 昨涨% / 竞价量能比 / 趋势
|
|
15
|
+
* / P1 竞价 / P2 弱转强 / P3 趋势 / 总分 / 标签
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 双语片段:默认中文,点右上角 EN 切换 */
|
|
19
|
+
function b(zh, en) { return `<span data-zh="${zh}" data-en="${en}">${zh}</span>`; }
|
|
20
|
+
function num(n, d = 2) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(d); }
|
|
21
|
+
function pct(n) { return (n == null || isNaN(n)) ? '—' : (n >= 0 ? '+' : '') + Number(n).toFixed(2) + '%'; }
|
|
22
|
+
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
23
|
+
|
|
24
|
+
function trendStr(t) {
|
|
25
|
+
if (!t) return '—';
|
|
26
|
+
const parts = [t.maBull ? '多头✓' : '多头✗'];
|
|
27
|
+
if (t.newHigh20) parts.push('20新✓');
|
|
28
|
+
if (t.newHigh60) parts.push('60新✓');
|
|
29
|
+
parts.push(`斜率${num(t.slope20, 1)}%/d`);
|
|
30
|
+
return parts.join(' ');
|
|
31
|
+
}
|
|
32
|
+
function labelCls(l) {
|
|
33
|
+
const s = String(l || '');
|
|
34
|
+
if (s.indexOf('超预期') >= 0 || s === '竞价涨停') return 'pass';
|
|
35
|
+
if (s.indexOf('弱转强') >= 0) return '';
|
|
36
|
+
if (s.indexOf('竞价强') >= 0 || s.indexOf('趋势') >= 0) return 'gap';
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/*
|
|
41
|
+
* 信号分项分组(两级表头):P1 竞价强势(35) + P2 弱转强/超预期(35) + P3 趋势(30)。
|
|
42
|
+
* 组内列顺序 = 渲染顺序,权重写进组标题,一眼看出谁占大头。
|
|
43
|
+
*/
|
|
44
|
+
const SCORE_GROUP = { key: 'sig', zh: '信号分项', en: 'Signals', cols: [['p1', 'P1 竞价', 35], ['p2', 'P2 弱转强', 35], ['p3', 'P3 趋势', 30]] };
|
|
45
|
+
const SCORE_COLS = SCORE_GROUP.cols;
|
|
46
|
+
|
|
47
|
+
/** 候选行。withBreakdown 控制是否渲染 P1/P2/P3 三个分项单元格。 */
|
|
48
|
+
function rowHtml(c, i, withBreakdown) {
|
|
49
|
+
const cells = withBreakdown
|
|
50
|
+
? SCORE_COLS.map(([k, , w]) => `<td class="num sc">${num((c.scoreItems ? c.scoreItems[k] : 0) * w, 1)}</td>`).join('')
|
|
51
|
+
: '';
|
|
52
|
+
const riskTag = c.risk ? `<span class="tag gap">${b('风险', 'risk')}</span>` : '';
|
|
53
|
+
return `<tr data-sector="${esc(c.sector)}" data-label="${esc(c.label)}"${c.bestInGroup ? ' data-best="1"' : ''} data-grank="${c.groupRank || ''}">
|
|
54
|
+
<td>${i + 1}</td>
|
|
55
|
+
<td><b>${esc(c.name)}</b><span class="src">${c.code.replace(/^[a-z]+/, '')}</span></td>
|
|
56
|
+
<td>${b(esc(c.sector), esc(c.sector))}</td>
|
|
57
|
+
<td>${esc(c.board)}</td>
|
|
58
|
+
<td class="num ${c.auctionChgPct >= 0 ? 'up' : 'down'}">${pct(c.auctionChgPct)}</td>
|
|
59
|
+
<td class="num ${c.yesterdayChgPct >= 0 ? 'up' : 'down'}">${pct(c.yesterdayChgPct)}</td>
|
|
60
|
+
<td class="num">${c.auctionVolRatio == null ? '—' : num(c.auctionVolRatio, 2)}</td>
|
|
61
|
+
<td class="trend">${esc(trendStr(c.trend))}</td>
|
|
62
|
+
${cells}
|
|
63
|
+
<td><span class="score">${num(c.total, 1)}</span></td>
|
|
64
|
+
<td class="wrap"><span class="tag ${labelCls(c.label)}">${esc(c.label)}</span>${riskTag}</td>
|
|
65
|
+
</tr>`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CSS = ` :root, :root[data-theme="light"] {
|
|
69
|
+
--bg: #fffbeb; --card: #fff; --text: #1c1917; --muted: #92400e;
|
|
70
|
+
--border: #fde68a; --accent: #b45309; --red: #dc2626; --green: #16a34a;
|
|
71
|
+
--blue: #2563eb; --amber: #d97706; --gold: #ca8a04; --slate: #64748b;
|
|
72
|
+
--btn-bg: #fff; --btn-fg: #b45309; --callout-bg: #fff7ed; --th-bg: #fefce8;
|
|
73
|
+
--grp-self-bg: #fed7aa; --grp-self-fg: #7c2d12; --grp-sub-self-bg: #fff7ed;
|
|
74
|
+
--tag-bg: #eff6ff; --tag-pass-bg: #fef2f2; --tag-gap-bg: #fff7ed; --score-bg: #fffbeb;
|
|
75
|
+
--sc-self-bg: rgba(217, 119, 6, 0.06); --note-fg: #57534e;
|
|
76
|
+
--disc-bg: #fef2f2; --disc-border: #fecaca; --disc-fg: #991b1b; --card-empty-bg: #fafaf9;
|
|
77
|
+
}
|
|
78
|
+
:root[data-theme="dark"] {
|
|
79
|
+
--bg: #0f1419; --card: #1a2129; --text: #e6edf3; --muted: #c9a684;
|
|
80
|
+
--border: #2a323c; --accent: #f59e0b; --red: #ff5b5b; --green: #26a69a;
|
|
81
|
+
--blue: #60a5fa; --amber: #fbbf24; --gold: #ffb74d; --slate: #94a3b8;
|
|
82
|
+
--btn-bg: #222b35; --btn-fg: #f59e0b; --callout-bg: #1f1c17; --th-bg: #222b35;
|
|
83
|
+
--grp-self-bg: #3a2a1a; --grp-self-fg: #fcd34d; --grp-sub-self-bg: #1f1c17;
|
|
84
|
+
--tag-bg: #1b2533; --tag-pass-bg: #2a1e1e; --tag-gap-bg: #1f1c17; --score-bg: #211d14;
|
|
85
|
+
--sc-self-bg: rgba(245, 158, 11, 0.10); --note-fg: #b9b3a8;
|
|
86
|
+
--disc-bg: #2a1e1e; --disc-border: #5b2a2a; --disc-fg: #fca5a5; --card-empty-bg: #14171c;
|
|
87
|
+
}
|
|
88
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
89
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", "Segoe UI", sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; padding: 24px 16px; }
|
|
90
|
+
.container { max-width: 1320px; margin: 0 auto; }
|
|
91
|
+
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
|
|
92
|
+
h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; }
|
|
93
|
+
h2 { font-size: 1.15rem; font-weight: 600; margin: 28px 0 12px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
|
|
94
|
+
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 14px; }
|
|
95
|
+
.topbtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: var(--btn-bg); color: var(--btn-fg); font-weight: 600; font-size: 0.85rem; padding: 7px 14px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
|
|
96
|
+
.topbtn:hover { background: var(--accent); color: var(--btn-bg); }
|
|
97
|
+
.topbar .btns { display: flex; gap: 8px; }
|
|
98
|
+
.meta-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 0.88rem; margin-bottom: 18px; }
|
|
99
|
+
.meta-bar .label { color: var(--muted); }
|
|
100
|
+
.meta-bar .value { font-weight: 600; }
|
|
101
|
+
.callout { background: var(--callout-bg); border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 10px; padding: 16px 20px; margin-bottom: 18px; }
|
|
102
|
+
.callout h3 { color: var(--accent); margin-bottom: 8px; }
|
|
103
|
+
.callout ul { margin: 6px 0 0 18px; font-size: 0.9rem; }
|
|
104
|
+
.callout li { margin-bottom: 5px; }
|
|
105
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; }
|
|
106
|
+
.up { color: var(--red); } .down { color: var(--green); } .flat { color: var(--slate); }
|
|
107
|
+
.score { display: inline-block; font-size: 0.95rem; font-weight: 700; padding: 2px 12px; border-radius: 6px; background: var(--score-bg); color: var(--gold); }
|
|
108
|
+
.tag { display: inline-block; font-size: 0.78rem; padding: 2px 8px; border-radius: 4px; margin: 2px 4px 2px 0; background: var(--tag-bg); color: var(--blue); }
|
|
109
|
+
.tag.pass { background: var(--tag-pass-bg); color: var(--red); }
|
|
110
|
+
.tag.gap { background: var(--tag-gap-bg); color: var(--amber); }
|
|
111
|
+
.table-wrap { overflow-x: auto; }
|
|
112
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
113
|
+
th, td { padding: 8px 9px; text-align: center; vertical-align: middle; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
114
|
+
th { color: var(--muted); font-weight: 600; background: var(--th-bg); }
|
|
115
|
+
th.grp { text-align: center; font-size: 0.8rem; border-bottom: 1px solid var(--border); background: var(--grp-self-bg); color: var(--grp-self-fg); font-weight: 700; }
|
|
116
|
+
th.grp-sub { background: var(--grp-sub-self-bg); color: var(--accent); }
|
|
117
|
+
td.num { text-align: center; font-variant-numeric: tabular-nums; }
|
|
118
|
+
td.sc { background: var(--sc-self-bg); }
|
|
119
|
+
.score-note { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.75; color: var(--note-fg); background: var(--callout-bg); border: 1px dashed var(--border); border-radius: 8px; padding: 10px 14px; }
|
|
120
|
+
td.wrap { white-space: normal; min-width: 130px; }
|
|
121
|
+
td .src { display: block; color: var(--slate); font-size: 0.72rem; }
|
|
122
|
+
td.trend { color: var(--muted); font-size: 0.8rem; }
|
|
123
|
+
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 0 0 12px; }
|
|
124
|
+
.toolrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; width: 100%; }
|
|
125
|
+
.toolrow .cap { font-size: 0.8rem; color: var(--muted); font-weight: 600; flex: none; }
|
|
126
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 999px; overflow: hidden; flex: none; }
|
|
127
|
+
.segbtn { border: 0; background: var(--btn-bg); color: var(--btn-fg); font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 16px; cursor: pointer; white-space: nowrap; }
|
|
128
|
+
.segbtn + .segbtn { border-left: 1px solid var(--border); }
|
|
129
|
+
.segbtn.active { background: var(--accent); color: #fff; }
|
|
130
|
+
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
131
|
+
.chip { border: 1px solid var(--border); background: var(--btn-bg); color: var(--text); font: inherit; font-size: 0.8rem; padding: 3px 11px; border-radius: 999px; cursor: pointer; }
|
|
132
|
+
.chip i { font-style: normal; color: var(--muted); margin-left: 3px; }
|
|
133
|
+
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
134
|
+
.chip.active i { color: inherit; opacity: 0.85; }
|
|
135
|
+
.bd-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted); cursor: pointer; white-space: nowrap; flex: none; }
|
|
136
|
+
tr.sector-row td { background: var(--th-bg); color: var(--accent); font-weight: 700; font-size: 0.88rem; }
|
|
137
|
+
table.hide-bd th.grp, table.hide-bd th.grp-sub, table.hide-bd td.sc { display: none; }
|
|
138
|
+
details { margin-top: 10px; }
|
|
139
|
+
summary { cursor: pointer; color: var(--accent); font-size: 0.88rem; font-weight: 600; }
|
|
140
|
+
.src { font-size: 0.78rem; color: var(--slate); margin-top: 8px; }
|
|
141
|
+
.disclaimer { margin-top: 26px; padding: 14px 18px; background: var(--disc-bg); border: 1px solid var(--disc-border); border-radius: 8px; font-size: 0.85rem; color: var(--disc-fg); }
|
|
142
|
+
.footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }`;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {object} m { cfg, tradeDate, scope, anchor, result, marketDate, runner }
|
|
146
|
+
* @returns {string} 完整 HTML
|
|
147
|
+
*/
|
|
148
|
+
function buildHTML(m) {
|
|
149
|
+
const { cfg, tradeDate, scope, anchor, result, marketDate, runner } = m;
|
|
150
|
+
const { candidates, groups, top, stats } = result;
|
|
151
|
+
const scopeLabel = scope === 'universe' ? b('全市场', 'Full market') : b('观察池', 'Watchlist');
|
|
152
|
+
|
|
153
|
+
// ---- 核心结论 ----
|
|
154
|
+
const bestGroup = groups[0];
|
|
155
|
+
const conclusion = [
|
|
156
|
+
`<b>${b(`候选 ${candidates.length} 只 / ${groups.length} 个行业(${scope === 'universe' ? '全市场' : '观察池'})`,
|
|
157
|
+
`${candidates.length} names across ${groups.length} industries (${scope === 'universe' ? 'full market' : 'watchlist'})`)}。</b>`
|
|
158
|
+
+ (bestGroup ? b(
|
|
159
|
+
`最强行业「${esc(bestGroup.sector)}」,组内最优 ${esc(bestGroup.best.name)}(${num(bestGroup.best.total, 1)})。`,
|
|
160
|
+
`Strongest industry "${esc(bestGroup.sector)}"; its top pick is ${esc(bestGroup.best.name)} (${num(bestGroup.best.total, 1)}).`) : ''),
|
|
161
|
+
top.length ? b(
|
|
162
|
+
`全市场前五:${top.slice(0, 5).map((c) => `${esc(c.name)}(${num(c.total, 1)})`).join('、')}。`,
|
|
163
|
+
`Top five: ${top.slice(0, 5).map((c) => `${esc(c.name)} (${num(c.total, 1)})`).join(', ')}.`) : b('本时段无候选。', 'No candidates.'),
|
|
164
|
+
b(
|
|
165
|
+
`漏斗:快照 ${stats.snapshotCount} → 有基线 ${stats.baselineCount} → 入选 ${stats.candidateCount}(缺口 ${stats.gapCount})。`,
|
|
166
|
+
`Funnel: snapshot ${stats.snapshotCount} → with baseline ${stats.baselineCount} → picked ${stats.candidateCount} (gaps ${stats.gapCount}).`),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
// ---- 两级表头:合并「信号分项」到 P1/P2/P3 ----
|
|
170
|
+
const topHead = `<tr><th rowspan="2">#</th><th rowspan="2">${b('标的', 'Name')}</th><th rowspan="2">${b('行业', 'Industry')}</th>
|
|
171
|
+
<th rowspan="2">${b('板块', 'Board')}</th><th rowspan="2">${b('今开', 'Open')}</th><th rowspan="2">${b('昨涨', 'Prev chg')}</th>
|
|
172
|
+
<th rowspan="2">${b('竞价量能比', 'Auction vol')}</th><th rowspan="2">${b('趋势', 'Trend')}</th>
|
|
173
|
+
<th colspan="${SCORE_COLS.length}" class="grp">${b(SCORE_GROUP.zh, SCORE_GROUP.en)}</th>
|
|
174
|
+
<th rowspan="2">${b('总分', 'Total')}</th><th rowspan="2">${b('标签', 'Label')}</th></tr>
|
|
175
|
+
<tr>${SCORE_COLS.map(([, zh, w]) => `<th class="grp-sub">${b(`${zh} · ${w}`, `${zh} · ${w}`)}</th>`).join('')}</tr>`;
|
|
176
|
+
|
|
177
|
+
const scoreNote = `<p class="score-note" id="scoreNote">${b(
|
|
178
|
+
`信号分项:<b class="sn-self">P1 竞价强势</b>(今开高开 + 竞价量能比)· <b>P2 弱转强/超预期</b>(昨日强 + 今日开幅符合或超预期)· <b>P3 趋势</b>(均线多头 + N 日新高 + 斜率)。三列为「0–1 归一化分 × 权重(P1 35 / P2 35 / P3 30)」的实际得分,相加即总分。`,
|
|
179
|
+
`Signals: <b class="sn-self">P1 auction strength</b> (gap-up + auction volume ratio) · <b>P2 turn-strong / beat</b> (yesterday strong + today gap in/above range) · <b>P3 trend</b> (MA bull + N-day high + slope). Each column shows actual points (0–1 normalized × weight: P1 35 / P2 35 / P3 30); the three columns sum to the total.`)}</p>`;
|
|
180
|
+
|
|
181
|
+
// ---- 候选明细 + 工具栏(标签过滤 + 行业过滤 + 双视图 + 分项开关)----
|
|
182
|
+
const listed = candidates.slice(0, cfg.report.maxRows);
|
|
183
|
+
const truncated = candidates.length - listed.length;
|
|
184
|
+
// 标签 chips(按出现顺序去重)
|
|
185
|
+
const labelCounts = {};
|
|
186
|
+
for (const c of listed) labelCounts[c.label] = (labelCounts[c.label] || 0) + 1;
|
|
187
|
+
const labelOrder = [];
|
|
188
|
+
for (const c of listed) if (!labelOrder.includes(c.label)) labelOrder.push(c.label);
|
|
189
|
+
// 行业 chips
|
|
190
|
+
const sectorCounts = {};
|
|
191
|
+
for (const c of listed) { const k = c.sector || '未分类'; sectorCounts[k] = (sectorCounts[k] || 0) + 1; }
|
|
192
|
+
const sectorOrder = groups.map((g) => g.sector).filter((s) => sectorCounts[s]);
|
|
193
|
+
for (const k of Object.keys(sectorCounts)) if (!sectorOrder.includes(k)) sectorOrder.push(k);
|
|
194
|
+
|
|
195
|
+
const bestCount = listed.reduce((n, c) => n + (c.bestInGroup ? 1 : 0), 0);
|
|
196
|
+
const top3Count = listed.reduce((n, c) => n + ((c.groupRank || 99) <= 3 ? 1 : 0), 0);
|
|
197
|
+
|
|
198
|
+
const labelChips = [`<button type="button" class="chip active" data-lfilter="">${b(`全部 ${listed.length}`, `All ${listed.length}`)}</button>`]
|
|
199
|
+
.concat([
|
|
200
|
+
`<button type="button" class="chip" data-lfilter="@best">${b('组内第一', 'Group #1')}<i>${bestCount}</i></button>`,
|
|
201
|
+
`<button type="button" class="chip" data-lfilter="@top3">${b('组内前三', 'Group top 3')}<i>${top3Count}</i></button>`,
|
|
202
|
+
])
|
|
203
|
+
.concat(labelOrder.map((l) => `<button type="button" class="chip" data-lfilter="${esc(l)}">${b(esc(l), esc(l))}<i>${labelCounts[l]}</i></button>`))
|
|
204
|
+
.join('');
|
|
205
|
+
const sectorChips = [`<button type="button" class="chip active" data-sfilter="">${b('全部行业', 'All sectors')}</button>`]
|
|
206
|
+
.concat(sectorOrder.map((s) => `<button type="button" class="chip" data-sfilter="${esc(s)}">${b(esc(s), esc(s))}<i>${sectorCounts[s]}</i></button>`))
|
|
207
|
+
.join('');
|
|
208
|
+
|
|
209
|
+
const toolbar = `<div class="toolbar">
|
|
210
|
+
<div class="seg" role="tablist">
|
|
211
|
+
<button type="button" id="viewScore" class="segbtn active">${b('按总分', 'By score')}</button>
|
|
212
|
+
<button type="button" id="viewSector" class="segbtn">${b('按行业', 'By industry')}</button>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="toolrow"><span class="cap">${b('标签', 'Label')}</span><span class="chips" id="lchips">${labelChips}</span></div>
|
|
215
|
+
<div class="toolrow"><span class="cap">${b('行业', 'Industry')}</span><span class="chips" id="schips">${sectorChips}</span></div>
|
|
216
|
+
<div class="toolrow"><label class="bd-toggle"><input type="checkbox" id="bdToggle" checked> ${b('分项得分', 'Breakdown')}</label></div>
|
|
217
|
+
</div>`;
|
|
218
|
+
|
|
219
|
+
// 分组分隔行:默认隐藏,「按行业」视图前端插入各组行首
|
|
220
|
+
const baseCols = 8 + SCORE_COLS.length + 2; // 基础8 + 分项3 + 总分/标签2 = 13
|
|
221
|
+
const sepRows = groups.map((g) =>
|
|
222
|
+
`<tr class="sector-row" data-sep="${esc(g.sector)}" hidden><td colspan="${baseCols}">${
|
|
223
|
+
b(esc(g.sector), esc(g.sector))} · ${b(`组内 ${g.size} 只,最优 ${esc(g.best.name)}(${num(g.best.total, 1)})`,
|
|
224
|
+
`${g.size} picked; top ${esc(g.best.name)} (${num(g.best.total, 1)})`)}</td></tr>`).join('');
|
|
225
|
+
const candRows = listed.map((c, i) => rowHtml(c, i, true)).join('');
|
|
226
|
+
|
|
227
|
+
// ---- 数据缺口 ----
|
|
228
|
+
const gapRows = (result.gaps || []).slice(0, 40)
|
|
229
|
+
.map((g) => `<tr><td>${esc(g.code.replace(/^[a-z]+/, ''))}</td><td>${esc(g.name)}</td><td>${esc(g.reason)}</td></tr>`).join('');
|
|
230
|
+
|
|
231
|
+
const provenance = b(
|
|
232
|
+
`竞价快照来自腾讯 qt.gtimg.cn 批量报价(今开 open 于 09:25 撮合后固定,近似竞价价;竞价量能比 = 竞价量 / 昨日全天量)。昨涨 + 趋势基线来自腾讯 ifzq fqkline 前复权日 K 线,锚定前一交易日 ${esc(anchor || marketDate || tradeDate)}${scope === 'universe' ? '(全市场基线缓存 preopen-baseline-*.json)' : '(观察池实时计算)'}。行业归属来自本地缓存 eod/data/industry-map.json。交易日由行情时间戳众数推导(${esc(marketDate || tradeDate)}),不依赖外部交易日历。运行环境:${esc(runner)}。`,
|
|
233
|
+
`Auction snapshot: Tencent qt.gtimg.cn batch quotes (open fixed at 09:25 auction close; auction vol ratio = auction vol / yesterday full-day vol). Yesterday-change + trend baseline: Tencent ifzq fqkline (pre-adjusted daily K), anchored to the prior trading day ${esc(anchor || marketDate || tradeDate)}${scope === 'universe' ? ' (full-market baseline cache preopen-baseline-*.json)' : ' (watchlist computed live)'}. Industry: local cache eod/data/industry-map.json. Trade date derived from quote-timestamp mode (${esc(marketDate || tradeDate)}); no external calendar. Runner: ${esc(runner)}.`);
|
|
234
|
+
|
|
235
|
+
const title = `${cfg.report.title} — ${tradeDate}`;
|
|
236
|
+
const titleEn = `${cfg.report.titleEn} — ${tradeDate}`;
|
|
237
|
+
|
|
238
|
+
return `<!DOCTYPE html>
|
|
239
|
+
<html lang="zh-CN" data-theme="dark">
|
|
240
|
+
<head>
|
|
241
|
+
<meta charset="UTF-8">
|
|
242
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
243
|
+
<title>${title}</title>
|
|
244
|
+
<style>${CSS}</style>
|
|
245
|
+
</head>
|
|
246
|
+
<body>
|
|
247
|
+
<div class="container">
|
|
248
|
+
<div class="topbar">
|
|
249
|
+
<div>
|
|
250
|
+
<h1>${b(cfg.report.title, cfg.report.titleEn)} · ${tradeDate}</h1>
|
|
251
|
+
<div class="sub">${b('盘前窗口 09:20–09:35 · 今开(open) 09:25 撮合后固定 · 满分 100', 'Pre-open window 09:20–09:35 · open fixed at 09:25 auction close · max 100')}</div>
|
|
252
|
+
</div>
|
|
253
|
+
<div class="btns">
|
|
254
|
+
<button id="themeBtn" type="button" class="topbtn" title="切换深浅色">☀</button>
|
|
255
|
+
<button id="langBtn" type="button" class="topbtn">EN</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div class="meta-bar">
|
|
260
|
+
<div><span class="label">${b('交易日:', 'Trade date:')}</span><span class="value">${tradeDate}</span></div>
|
|
261
|
+
<div><span class="label">${b('范围:', 'Scope:')}</span><span class="value">${scopeLabel}</span></div>
|
|
262
|
+
<div><span class="label">${b('行情日:', 'Market date:')}</span><span class="value">${esc(marketDate || tradeDate)}</span></div>
|
|
263
|
+
<div><span class="label">${b('基线锚定日:', 'Baseline anchor:')}</span><span class="value">${esc(anchor || '—')}</span></div>
|
|
264
|
+
<div><span class="label">${b('快照:', 'Snapshot:')}</span><span class="value">${stats.snapshotCount}</span></div>
|
|
265
|
+
<div><span class="label">${b('基线:', 'Baseline:')}</span><span class="value">${stats.baselineCount}</span></div>
|
|
266
|
+
<div><span class="label">${b('候选:', 'Candidates:')}</span><span class="value up">${stats.candidateCount}</span></div>
|
|
267
|
+
<div><span class="label">${b('行业组:', 'Groups:')}</span><span class="value">${stats.groupCount}</span></div>
|
|
268
|
+
<div><span class="label">${b('缺口:', 'Gaps:')}</span><span class="value">${stats.gapCount}</span></div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div class="callout">
|
|
272
|
+
<h3>${b('核心结论(先行)', 'Key Conclusions (Up Front)')}</h3>
|
|
273
|
+
<ul>${conclusion.map((c) => `<li>${c}</li>`).join('')}</ul>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<h2>${b(`候选明细(${listed.length} 只)`, `Candidates (${listed.length})`)}</h2>
|
|
277
|
+
${listed.length ? `${scoreNote}${toolbar}
|
|
278
|
+
<div class="table-wrap"><table id="candTable"><thead>${topHead}</thead>
|
|
279
|
+
<tbody id="candBody">${candRows}${sepRows}</tbody></table></div>
|
|
280
|
+
${truncated > 0 ? `<p class="src">${b(`另有 ${truncated} 只超出展示上限(report.maxRows)未列出。`, `${truncated} more candidates not listed (report.maxRows cap).`)}</p>` : ''}`
|
|
281
|
+
: `<div class="card">${b('本时段无满足全部门槛的候选。', 'No candidate passed all gates.')}</div>`}
|
|
282
|
+
|
|
283
|
+
<h2>${b('数据缺口', 'Data Gaps')}</h2>
|
|
284
|
+
<div class="card">
|
|
285
|
+
${cfg.report.showDataGap && gapRows ? `<details><summary>${b(`被剔除/缺数据明细(最多 40 条,共 ${result.gaps.length})`, `Excluded / missing-data details (up to 40 of ${result.gaps.length})`)}</summary>
|
|
286
|
+
<div class="table-wrap"><table><thead><tr><th>${b('代码', 'Code')}</th><th>${b('名称', 'Name')}</th><th>${b('原因', 'Reason')}</th></tr></thead>
|
|
287
|
+
<tbody>${gapRows}</tbody></table></div></details>` : b('无数据缺口记录。', 'No data-gap records.')}
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<h2>${b('数据口径与偏差声明', 'Data Provenance and Deviations')}</h2>
|
|
291
|
+
<div class="card"><p class="body-text">${provenance}</p></div>
|
|
292
|
+
|
|
293
|
+
<div class="disclaimer">${b('本报告为程序化观察输出,仅供个人研究参考,不构成任何投资建议。盘前候选基于集合竞价快照筛选,09:25 后价格仍可能变动,涨停/一字板存在买不到的风险,需自行确认可成交性与流动性。开盘快照存档于 eod/data/preopen-' + tradeDate + '.json,收盘后由 preopen_backfill.js 回填当日涨跌幅/是否涨停并统计命中率。',
|
|
294
|
+
'This report is programmatic observation output for personal research only and is not investment advice. Pre-open candidates are screened on the auction snapshot; prices may still move after 09:25, and limit-up / one-word boards may be unbuyable — verify tradability and liquidity yourself. The snapshot is archived to eod/data/preopen-' + tradeDate + '.json; after close preopen_backfill.js back-fills the day change / limit-up and computes the hit rate.')}</div>
|
|
295
|
+
<div class="footer">candidate-pool · preopen screener v${cfg.version} · ${scopeLabel} · ${tradeDate}</div>
|
|
296
|
+
</div>
|
|
297
|
+
<script>
|
|
298
|
+
(function () {
|
|
299
|
+
var themeBtn = document.getElementById('themeBtn');
|
|
300
|
+
var root = document.documentElement;
|
|
301
|
+
var savedTheme = null;
|
|
302
|
+
try { savedTheme = localStorage.getItem('report-theme'); } catch (e) {}
|
|
303
|
+
if (savedTheme === 'light' || savedTheme === 'dark') root.setAttribute('data-theme', savedTheme);
|
|
304
|
+
function syncThemeLabel() { themeBtn.textContent = (root.getAttribute('data-theme') === 'dark') ? '☀' : '🌙'; }
|
|
305
|
+
syncThemeLabel();
|
|
306
|
+
themeBtn.addEventListener('click', function () {
|
|
307
|
+
var t = (root.getAttribute('data-theme') === 'dark') ? 'light' : 'dark';
|
|
308
|
+
root.setAttribute('data-theme', t);
|
|
309
|
+
try { localStorage.setItem('report-theme', t); } catch (e) {}
|
|
310
|
+
syncThemeLabel();
|
|
311
|
+
});
|
|
312
|
+
var lang = 'zh';
|
|
313
|
+
var btn = document.getElementById('langBtn');
|
|
314
|
+
var nodes = document.querySelectorAll('[data-zh]');
|
|
315
|
+
nodes.forEach(function (el) { if (!el.getAttribute('data-zh')) el.setAttribute('data-zh', el.innerHTML); });
|
|
316
|
+
function apply(target) {
|
|
317
|
+
nodes.forEach(function (el) {
|
|
318
|
+
var val = el.getAttribute('data-' + target);
|
|
319
|
+
if (val !== null && val !== '') el.innerHTML = val;
|
|
320
|
+
});
|
|
321
|
+
document.documentElement.lang = (target === 'zh') ? 'zh-CN' : 'en';
|
|
322
|
+
document.title = (target === 'zh') ? '${title}' : '${titleEn}';
|
|
323
|
+
btn.textContent = (target === 'zh') ? 'EN' : '中文';
|
|
324
|
+
}
|
|
325
|
+
btn.addEventListener('click', function () { lang = (lang === 'zh') ? 'en' : 'zh'; apply(lang); });
|
|
326
|
+
apply(lang);
|
|
327
|
+
|
|
328
|
+
// 候选明细:视图切换 + 标签过滤 + 行业过滤(AND)+ 分项开关。
|
|
329
|
+
// 行服务端渲染好;「按行业」把行重排进分组分隔行之下,不重建 DOM。
|
|
330
|
+
var tbody = document.getElementById('candBody');
|
|
331
|
+
if (tbody) {
|
|
332
|
+
var candRows = Array.prototype.slice.call(tbody.querySelectorAll('tr[data-sector]'));
|
|
333
|
+
var sepOrder = [];
|
|
334
|
+
Array.prototype.forEach.call(tbody.querySelectorAll('tr[data-sep]'), function (tr) { sepOrder.push(tr); });
|
|
335
|
+
var candView = 'score', lFilter = '', sFilter = '';
|
|
336
|
+
var viewScoreBtn = document.getElementById('viewScore');
|
|
337
|
+
var viewSectorBtn = document.getElementById('viewSector');
|
|
338
|
+
function want(tr) {
|
|
339
|
+
var okL = true, okS = true;
|
|
340
|
+
if (lFilter === '@best') okL = tr.hasAttribute('data-best');
|
|
341
|
+
else if (lFilter === '@top3') { var g = parseInt(tr.getAttribute('data-grank'), 10); okL = g >= 1 && g <= 3; }
|
|
342
|
+
else if (lFilter) okL = tr.getAttribute('data-label') === lFilter;
|
|
343
|
+
if (sFilter) okS = tr.getAttribute('data-sector') === sFilter;
|
|
344
|
+
return okL && okS;
|
|
345
|
+
}
|
|
346
|
+
function renderCand() {
|
|
347
|
+
var frag = document.createDocumentFragment();
|
|
348
|
+
var ranked = (lFilter === '@best' || lFilter === '@top3');
|
|
349
|
+
var shown = [];
|
|
350
|
+
if (candView === 'score') {
|
|
351
|
+
candRows.forEach(function (tr) { if (want(tr)) { frag.appendChild(tr); shown.push(tr); } });
|
|
352
|
+
} else {
|
|
353
|
+
sepOrder.forEach(function (sep) {
|
|
354
|
+
var sec = sep.getAttribute('data-sep');
|
|
355
|
+
if (sFilter && sec !== sFilter) return;
|
|
356
|
+
var groupRows = [];
|
|
357
|
+
candRows.forEach(function (tr) { if (tr.getAttribute('data-sector') === sec && want(tr)) groupRows.push(tr); });
|
|
358
|
+
if (!groupRows.length) return;
|
|
359
|
+
sep.hidden = false; frag.appendChild(sep);
|
|
360
|
+
groupRows.forEach(function (tr) { frag.appendChild(tr); shown.push(tr); });
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
shown.forEach(function (tr) {
|
|
364
|
+
var cell = tr.cells[0];
|
|
365
|
+
if (ranked) {
|
|
366
|
+
if (!tr.dataset.idx) tr.dataset.idx = cell.textContent;
|
|
367
|
+
cell.textContent = tr.getAttribute('data-grank') || tr.dataset.idx;
|
|
368
|
+
} else if (tr.dataset.idx) {
|
|
369
|
+
cell.textContent = tr.dataset.idx;
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
|
373
|
+
tbody.appendChild(frag);
|
|
374
|
+
}
|
|
375
|
+
function setView(v) {
|
|
376
|
+
candView = v;
|
|
377
|
+
viewScoreBtn.classList.toggle('active', v === 'score');
|
|
378
|
+
viewSectorBtn.classList.toggle('active', v === 'sector');
|
|
379
|
+
renderCand();
|
|
380
|
+
}
|
|
381
|
+
viewScoreBtn.addEventListener('click', function () { setView('score'); });
|
|
382
|
+
viewSectorBtn.addEventListener('click', function () { setView('sector'); });
|
|
383
|
+
function bindChips(id, setter) {
|
|
384
|
+
var wrap = document.getElementById(id);
|
|
385
|
+
wrap.addEventListener('click', function (e) {
|
|
386
|
+
var chip = e.target.closest('.chip');
|
|
387
|
+
if (!chip) return;
|
|
388
|
+
setter(chip.getAttribute('data-lfilter') != null ? chip.getAttribute('data-lfilter') : chip.getAttribute('data-sfilter') || '');
|
|
389
|
+
Array.prototype.forEach.call(wrap.querySelectorAll('.chip'), function (c) {
|
|
390
|
+
var v = c.getAttribute('data-lfilter') != null ? c.getAttribute('data-lfilter') : (c.getAttribute('data-sfilter') || '');
|
|
391
|
+
var cur = (id === 'lchips') ? lFilter : sFilter;
|
|
392
|
+
c.classList.toggle('active', v === cur);
|
|
393
|
+
});
|
|
394
|
+
// 组内前三要求同组相邻:选中时自动切「按行业」
|
|
395
|
+
if (lFilter === '@top3' && candView !== 'sector') { setView('sector'); return; }
|
|
396
|
+
renderCand();
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
bindChips('lchips', function (v) { lFilter = v; });
|
|
400
|
+
bindChips('schips', function (v) { sFilter = v; });
|
|
401
|
+
var bdToggle = document.getElementById('bdToggle');
|
|
402
|
+
var scoreNote = document.getElementById('scoreNote');
|
|
403
|
+
function applyBd() {
|
|
404
|
+
var on = bdToggle.checked;
|
|
405
|
+
document.getElementById('candTable').classList.toggle('hide-bd', !on);
|
|
406
|
+
if (scoreNote) scoreNote.style.display = on ? '' : 'none';
|
|
407
|
+
}
|
|
408
|
+
bdToggle.addEventListener('change', applyBd);
|
|
409
|
+
applyBd();
|
|
410
|
+
renderCand();
|
|
411
|
+
}
|
|
412
|
+
})();
|
|
413
|
+
</script>
|
|
414
|
+
</body>
|
|
415
|
+
</html>`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
module.exports = { buildHTML };
|