candidate-pool 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +245 -0
- package/README.md +397 -0
- package/bin/candidate-pool.js +23 -0
- package/bin/tail-screener.js +22 -0
- package/build_universe.js +231 -0
- package/candidates.example.json +269 -0
- package/db/.env.example +13 -0
- package/db/backfill.js +71 -0
- package/db/clients.js +48 -0
- package/db/d1client.js +69 -0
- package/db/ddl.js +82 -0
- package/db/diff.js +223 -0
- package/db/diff_selftest.js +124 -0
- package/db/enrich_stock_base.js +138 -0
- package/db/migrate.js +177 -0
- package/db/normalize.js +129 -0
- package/db/persist.js +90 -0
- package/db/query_local.js +43 -0
- package/db/schema.sql +225 -0
- package/db/sqlite_client.js +81 -0
- package/db/verify_sync.js +107 -0
- package/db/write_live.js +54 -0
- package/eod/build_industry_map.js +146 -0
- package/eod/data/industry-map.json +1 -0
- package/eod/lib/http.js +188 -0
- package/eod/lib/market.js +345 -0
- package/eod/lib/report.js +329 -0
- package/eod/lib/screen.js +355 -0
- package/eod/lib/sector.js +96 -0
- package/eod/lib/store.js +204 -0
- package/eod/lib/store_d1.js +293 -0
- package/eod/lib/trading.js +252 -0
- package/eod/lib/trading.selftest.js +283 -0
- package/eod/tail.config.js +212 -0
- package/eod/tail_screener.js +373 -0
- package/gen_candidates.js +994 -0
- package/notify.js +236 -0
- package/notify_config.example.json +14 -0
- package/package.json +96 -0
- package/paths.js +127 -0
- package/paths.selftest.js +142 -0
- package/scaffold.js +199 -0
- package/scaffold.selftest.js +153 -0
- package/time.js +17 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* eod/lib/report.js —— 自包含双语 HTML 报告(FR-08)
|
|
4
|
+
* ---------------------------------------------------------------------------
|
|
5
|
+
* 视觉与交互完全复用 gen_candidates.js 的既有样式(暖色系 + data-zh/data-en
|
|
6
|
+
* 双语切换),保证两份报告放在一起不违和。零依赖,输出单文件 HTML。
|
|
7
|
+
*
|
|
8
|
+
* 结构:
|
|
9
|
+
* topbar(标题 + 语言切换)→ meta-bar(交易日 / 口径 / 漏斗数字)
|
|
10
|
+
* → 核心结论 → TOP N 明细(含六项分项得分)
|
|
11
|
+
* → 分行业分组卡片 → 淘汰漏斗统计 → 数据缺口(折叠)
|
|
12
|
+
* → 数据口径声明 + 免责声明
|
|
13
|
+
* ---------------------------------------------------------------------------
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { BOARD_LABEL } = require('./screen');
|
|
17
|
+
|
|
18
|
+
/** 双语片段:默认渲染中文,点击右上角按钮切换 */
|
|
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 yi(n) { return (n == null || isNaN(n)) ? '—' : Number(n).toFixed(1) + ' 亿'; }
|
|
23
|
+
function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* 六维分项按「这分是谁给的」分两组(2026-09-21):
|
|
27
|
+
* · 个股自身 65 分 = 尾盘动能 30 + 量能 20 + 位置 15 —— 这只票「自己在走」的证据,**重点看这组**;
|
|
28
|
+
* · 环境与质量 35 分 = 板块 15 + 均价线 10 + 换手 10 —— 板块与流动性给的背景分。
|
|
29
|
+
* 只看总分会被「板块热 + 换手够」抬高分数骗到,故两组在表头/底色上必须可分辨。
|
|
30
|
+
* 组内按权重降序(权重大的先看)—— 顺序即渲染顺序。
|
|
31
|
+
* ⚠️ 与 web/src/views/TailView.vue 的 SCORE_GROUPS 是同一份口径,改一处必须改另一处。
|
|
32
|
+
*/
|
|
33
|
+
const SCORE_GROUPS = [
|
|
34
|
+
{
|
|
35
|
+
key: 'self', cls: 'self', max: 65,
|
|
36
|
+
zh: '个股自身 · 重点看(65 分)', en: 'The stock itself · focus (65)',
|
|
37
|
+
cols: [
|
|
38
|
+
['tailMomentum', '尾盘动能', 'Tail momentum'],
|
|
39
|
+
['volume', '量能', 'Volume'],
|
|
40
|
+
['position', '位置', 'Position'],
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: 'ctx', cls: 'ctx', max: 35,
|
|
45
|
+
zh: '环境与质量(35 分)', en: 'Context & quality (35)',
|
|
46
|
+
cols: [
|
|
47
|
+
['sector', '板块', 'Sector'],
|
|
48
|
+
['avgLine', '均价线', 'VWAP'],
|
|
49
|
+
['turnoverFit', '换手', 'Turnover'],
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** 扁平化后的六列(顺序 = 渲染顺序),供行内单元格逐个取值。 */
|
|
55
|
+
const SCORE_COLS = SCORE_GROUPS.flatMap((g) => g.cols);
|
|
56
|
+
/** 与 SCORE_COLS 一一对应的单元格 class:分组底色 + 每组首列加左分隔线。 */
|
|
57
|
+
const SCORE_CELL_CLS = SCORE_GROUPS.flatMap((g) =>
|
|
58
|
+
g.cols.map((_, i) => `num sc-${g.cls}${i === 0 ? ' sc-start' : ''}`));
|
|
59
|
+
|
|
60
|
+
/** 两级表头第一行:两个分组的合并标题(含组权重,一眼看出哪个是重点) */
|
|
61
|
+
function scoreHeadTop() {
|
|
62
|
+
return SCORE_GROUPS
|
|
63
|
+
.map((g) => `<th colspan="${g.cols.length}" class="grp grp-${g.cls}">${b(g.zh, g.en)}</th>`)
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
/** 两级表头第二行:六项子分列名 */
|
|
67
|
+
function scoreHeadSub() {
|
|
68
|
+
return SCORE_GROUPS.map((g) =>
|
|
69
|
+
g.cols.map(([, zh, en], i) => `<th class="grp-sub grp-${g.cls}${i === 0 ? ' sc-start' : ''}">${b(zh, en)}</th>`).join(''))
|
|
70
|
+
.join('');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 单条候选的行(用于 TOP 表 / 分组卡片) */
|
|
74
|
+
function rowHtml(c, i, cfg, withBreakdown) {
|
|
75
|
+
const tail = c.tail || {};
|
|
76
|
+
const warn = cfg.score.tailUpRatioWarn;
|
|
77
|
+
const tagBits = [];
|
|
78
|
+
if (c.bestInGroup) tagBits.push(`<span class="tag pass">${b('组内最优', 'best in group')}</span>`);
|
|
79
|
+
if (tail.upRatio != null && tail.upRatio < warn) {
|
|
80
|
+
tagBits.push(`<span class="tag gap">${b('拉升不连贯', 'choppy tail')}</span>`);
|
|
81
|
+
}
|
|
82
|
+
const cells = withBreakdown
|
|
83
|
+
? SCORE_COLS.map(([k], idx) => `<td class="${SCORE_CELL_CLS[idx]}">${num(c.score ? c.score[k] : null, 1)}</td>`).join('')
|
|
84
|
+
: '';
|
|
85
|
+
return `<tr>
|
|
86
|
+
<td>${i + 1}</td>
|
|
87
|
+
<td><b>${esc(c.name)}</b><span class="src">${c.code.replace(/^[a-z]+/, '')}</span></td>
|
|
88
|
+
<td>${b(esc(c.sector), esc(c.sector))}</td>
|
|
89
|
+
<td class="up">${pct(c.chgPct)}</td>
|
|
90
|
+
<td>${num(tail.segPct, 2)}%</td>
|
|
91
|
+
<td>${num(c.volRatio)}</td>
|
|
92
|
+
<td>${num(c.turnover, 1)}%</td>
|
|
93
|
+
<td>${yi(c.floatCapYi)}</td>
|
|
94
|
+
${cells}
|
|
95
|
+
<td><span class="score">${num(c.total, 1)}</span></td>
|
|
96
|
+
<td class="wrap">${tagBits.join('') || '—'}</td>
|
|
97
|
+
</tr>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const CSS = ` :root {
|
|
101
|
+
--bg: #fffbeb; --card: #fff; --text: #1c1917; --muted: #92400e;
|
|
102
|
+
--border: #fde68a; --accent: #b45309; --red: #dc2626; --green: #16a34a;
|
|
103
|
+
--blue: #2563eb; --amber: #d97706; --gold: #ca8a04; --slate: #64748b;
|
|
104
|
+
}
|
|
105
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
106
|
+
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; }
|
|
107
|
+
.container { max-width: 1040px; margin: 0 auto; }
|
|
108
|
+
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 4px; }
|
|
109
|
+
h1 { font-size: 1.55rem; font-weight: 700; margin-bottom: 6px; }
|
|
110
|
+
h2 { font-size: 1.15rem; font-weight: 600; margin: 30px 0 12px; padding-bottom: 6px; border-bottom: 2px solid var(--border); }
|
|
111
|
+
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 14px; }
|
|
112
|
+
#langBtn { flex: none; cursor: pointer; border: 1px solid var(--accent); background: #fff; color: var(--accent); font-weight: 600; font-size: 0.85rem; padding: 7px 16px; border-radius: 999px; font-family: inherit; transition: all .15s; white-space: nowrap; }
|
|
113
|
+
#langBtn:hover { background: var(--accent); color: #fff; }
|
|
114
|
+
.meta-bar { display: flex; flex-wrap: wrap; gap: 8px 22px; font-size: 0.88rem; margin-bottom: 18px; }
|
|
115
|
+
.meta-bar .label { color: var(--muted); }
|
|
116
|
+
.meta-bar .value { font-weight: 600; }
|
|
117
|
+
.callout { background: #fff7ed; border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: 10px; padding: 16px 20px; margin-bottom: 18px; }
|
|
118
|
+
.callout h3 { color: var(--accent); margin-bottom: 8px; }
|
|
119
|
+
.callout ul { margin: 6px 0 0 18px; font-size: 0.9rem; }
|
|
120
|
+
.callout li { margin-bottom: 5px; }
|
|
121
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 18px 20px; margin-bottom: 16px; }
|
|
122
|
+
.group-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
|
|
123
|
+
.group-head h3 { font-size: 1.02rem; }
|
|
124
|
+
.group-head .g-meta { color: var(--muted); font-size: 0.82rem; }
|
|
125
|
+
.up { color: var(--red); }
|
|
126
|
+
.down { color: var(--green); }
|
|
127
|
+
.flat { color: var(--slate); }
|
|
128
|
+
.score { display: inline-block; font-size: 0.95rem; font-weight: 700; padding: 2px 12px; border-radius: 6px; background: #fffbeb; color: var(--gold); }
|
|
129
|
+
.tag { display: inline-block; font-size: 0.78rem; padding: 2px 8px; border-radius: 4px; margin: 2px 4px 2px 0; background: #eff6ff; color: var(--blue); }
|
|
130
|
+
.tag.fail { background: #f1f5f9; color: var(--slate); }
|
|
131
|
+
.tag.pass { background: #fef2f2; color: var(--red); }
|
|
132
|
+
.tag.gap { background: #fffbeb; color: var(--amber); }
|
|
133
|
+
.table-wrap { overflow-x: auto; }
|
|
134
|
+
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
|
135
|
+
th, td { padding: 8px 9px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
|
136
|
+
th { color: var(--muted); font-weight: 600; background: #fefce8; }
|
|
137
|
+
/* 六维分项分两组:表头两级 + 组底色 + 组首左分隔线,让「重点看哪组」不用读文字 */
|
|
138
|
+
th.grp { text-align: center; font-size: 0.8rem; border-bottom: 1px solid var(--border); }
|
|
139
|
+
th.grp-self { background: #fed7aa; color: #7c2d12; font-weight: 700; }
|
|
140
|
+
th.grp-ctx { background: #e2e8f0; color: #475569; }
|
|
141
|
+
th.grp-sub.grp-self { background: #fff7ed; color: var(--accent); }
|
|
142
|
+
th.grp-sub.grp-ctx { background: #f1f5f9; color: var(--slate); }
|
|
143
|
+
th.sc-start, td.sc-start { border-left: 2px solid var(--border); }
|
|
144
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
145
|
+
td.sc-self { background: rgba(217, 119, 6, 0.06); }
|
|
146
|
+
td.sc-ctx { background: rgba(100, 116, 139, 0.05); }
|
|
147
|
+
.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; }
|
|
148
|
+
.score-note .sn-self { color: var(--accent); }
|
|
149
|
+
.score-note .sn-ctx { color: var(--slate); }
|
|
150
|
+
td.wrap { white-space: normal; min-width: 150px; }
|
|
151
|
+
td .src { display: block; color: var(--slate); font-size: 0.72rem; }
|
|
152
|
+
details { margin-top: 10px; }
|
|
153
|
+
summary { cursor: pointer; color: var(--accent); font-size: 0.88rem; font-weight: 600; }
|
|
154
|
+
.src { font-size: 0.78rem; color: var(--slate); margin-top: 8px; }
|
|
155
|
+
.disclaimer { margin-top: 26px; padding: 14px 18px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px; font-size: 0.85rem; color: #991b1b; }
|
|
156
|
+
.footer { margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.8rem; text-align: center; }`;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {object} m { cfg, tradeDate, mode, cutHHMM, segFrom, result, meta, runner }
|
|
160
|
+
* @returns {string} 完整 HTML
|
|
161
|
+
*/
|
|
162
|
+
function buildHTML(m) {
|
|
163
|
+
const { cfg, tradeDate, mode, cutHHMM, segFrom, result, meta, runner } = m;
|
|
164
|
+
const { candidates, groups, top, stats } = result;
|
|
165
|
+
// 三种口径必须在报告顶部一眼可辨:盘中模式的「尾盘段」是滚动窗口近似,
|
|
166
|
+
// 若标签写得像正式结果,复盘时会把近似值当基准(口径混淆)。
|
|
167
|
+
const modeLabel = mode === 'observe'
|
|
168
|
+
? b(`观察模式(口径 ${cutHHMM},未固定)`, `observe mode (cut ${cutHHMM}, not final)`)
|
|
169
|
+
: mode === 'intraday'
|
|
170
|
+
? b(`盘中模式(口径 ${cutHHMM},尾盘段=最近窗口近似 · 非正式结果)`,
|
|
171
|
+
`intraday mode (cut ${cutHHMM}, rolling-window approximation — NOT final)`)
|
|
172
|
+
: b(`固定口径 ${cutHHMM}`, `fixed cut ${cutHHMM}`);
|
|
173
|
+
|
|
174
|
+
// ---- 核心结论 ----
|
|
175
|
+
const bestGroup = groups[0];
|
|
176
|
+
const conclusion = [
|
|
177
|
+
`<b>${b(`候选 ${candidates.length} 只 / ${groups.length} 个行业`, `${candidates.length} names across ${groups.length} industries`)}。</b>`
|
|
178
|
+
+ (bestGroup ? b(
|
|
179
|
+
`最强行业「${esc(bestGroup.sector)}」(中位涨幅 ${num(bestGroup.best.sectorMedianChg, 2)}%),组内最优 ${esc(bestGroup.best.name)}。`,
|
|
180
|
+
`Strongest industry "${esc(bestGroup.sector)}" (median ${num(bestGroup.best.sectorMedianChg, 2)}%); its top pick is ${esc(bestGroup.best.name)}.`) : ''),
|
|
181
|
+
top.length ? b(
|
|
182
|
+
`全市场前五:${top.slice(0, 5).map((c) => `${esc(c.name)}(${num(c.total, 1)})`).join('、')}。`,
|
|
183
|
+
`Top five: ${top.slice(0, 5).map((c) => `${esc(c.name)} (${num(c.total, 1)})`).join(', ')}.`) : b('本时段无候选。', 'No candidates in this window.'),
|
|
184
|
+
b(
|
|
185
|
+
`漏斗:快照 ${stats.snapshotCount} → 初筛通过 ${stats.prePassCount} → 尾盘段校验后 ${stats.candidateCount}。`,
|
|
186
|
+
`Funnel: snapshot ${stats.snapshotCount} → pre-filter pass ${stats.prePassCount} → after tail checks ${stats.candidateCount}.`),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
// ---- TOP N 表 ----
|
|
190
|
+
const topRows = top.map((c, i) => rowHtml(c, i, cfg, true)).join('');
|
|
191
|
+
// 两级表头:第一行把六个子分切成「个股自身 65 / 环境与质量 35」两块,
|
|
192
|
+
// 第一行列宽靠 colspan 自动对齐到下面的子分列,不需要额外配 colgroup。
|
|
193
|
+
const topHead = `<tr><th rowspan="2">#</th><th rowspan="2">${b('标的', 'Name')}</th><th rowspan="2">${b('行业', 'Industry')}</th>
|
|
194
|
+
<th rowspan="2">${b('涨幅', 'Chg')}</th><th rowspan="2">${b('尾盘段', 'Tail seg')}</th><th rowspan="2">${b('量比', 'Vol ratio')}</th>
|
|
195
|
+
<th rowspan="2">${b('换手', 'Turnover')}</th><th rowspan="2">${b('流通值', 'Float cap')}</th>
|
|
196
|
+
${scoreHeadTop()}
|
|
197
|
+
<th rowspan="2">${b('总分', 'Total')}</th><th rowspan="2">${b('标记', 'Flags')}</th></tr>
|
|
198
|
+
<tr>${scoreHeadSub()}</tr>`;
|
|
199
|
+
|
|
200
|
+
// 读分说明:不解释这一步,用户只会看到一堆等宽数字,不知道哪几个该当真。
|
|
201
|
+
const scoreNote = `<p class="score-note">${b(
|
|
202
|
+
`六维分项按「这分是谁给的」分成两组:<b class='sn-self'>个股自身 65 分</b>(尾盘动能 30 + 量能 20 + 位置 15)—— 这只票自己在走出来的证据,<b>重点看这一组</b>;<b class='sn-ctx'>环境与质量 35 分</b>(板块 15 + 均价线 10 + 换手 10)—— 板块与流动性给的背景分,靠它撑起来的高分不可信。`,
|
|
203
|
+
`<b class='sn-self'>The stock itself — 65 pts</b> (tail momentum 30 + volume 20 + position 15): evidence this name moved on its own — <b>this is the group to read first</b>. <b class='sn-ctx'>Context & quality — 35 pts</b> (sector 15 + VWAP 10 + turnover 10): background handed over by the sector and by liquidity; a high total carried by this group alone is not trustworthy.`)}</p>`;
|
|
204
|
+
|
|
205
|
+
// ---- 分组卡片 ----
|
|
206
|
+
const groupCards = groups.map((g) => {
|
|
207
|
+
const rows = g.shown.map((c, i) => rowHtml(c, i, cfg, false)).join('');
|
|
208
|
+
return `<div class="card">
|
|
209
|
+
<div class="group-head">
|
|
210
|
+
<h3>${esc(g.sector)}</h3>
|
|
211
|
+
<span class="g-meta">${b(`组内 ${g.size} 只(展示 ${g.shown.length})`, `${g.size} picked, showing ${g.shown.length}`)}
|
|
212
|
+
· ${b('行业中位涨幅', 'industry median')} ${pct(g.best.sectorMedianChg)}</span>
|
|
213
|
+
</div>
|
|
214
|
+
<div class="table-wrap"><table>
|
|
215
|
+
<thead><tr><th>#</th><th>${b('标的', 'Name')}</th><th>${b('行业', 'Industry')}</th>
|
|
216
|
+
<th>${b('涨幅', 'Chg')}</th><th>${b('尾盘段', 'Tail seg')}</th><th>${b('量比', 'Vol ratio')}</th>
|
|
217
|
+
<th>${b('换手', 'Turnover')}</th><th>${b('流通值', 'Float cap')}</th>
|
|
218
|
+
<th>${b('总分', 'Total')}</th><th>${b('标记', 'Flags')}</th></tr></thead>
|
|
219
|
+
<tbody>${rows}</tbody>
|
|
220
|
+
</table></div>
|
|
221
|
+
</div>`;
|
|
222
|
+
}).join('');
|
|
223
|
+
|
|
224
|
+
// ---- 淘汰漏斗 ----
|
|
225
|
+
const funnelRows = Object.entries(stats.preByReason)
|
|
226
|
+
.sort((a, c) => c[1] - a[1])
|
|
227
|
+
.map(([r, n]) => `<tr><td>${b(esc(r), esc(r))}</td><td>${n}</td></tr>`).join('');
|
|
228
|
+
const tailRows = Object.entries(stats.tailByReason)
|
|
229
|
+
.sort((a, c) => c[1] - a[1])
|
|
230
|
+
.map(([r, n]) => `<tr><td>${b(esc(r), esc(r))}</td><td>${n}</td></tr>`).join('');
|
|
231
|
+
|
|
232
|
+
// ---- 数据缺口 ----
|
|
233
|
+
const gapRows = result.dropped.filter((d) => d.reason.includes('数据')).slice(0, 40)
|
|
234
|
+
.map((d) => `<tr><td>${esc(d.code)}</td><td>${esc(d.name)}</td><td>${esc(d.reason)}</td></tr>`).join('');
|
|
235
|
+
|
|
236
|
+
const provenance = b(
|
|
237
|
+
`全市场快照来自腾讯 qt.gtimg.cn 批量报价(${meta.slots} 个槽位 / ${meta.batches} 批 / 失败 ${meta.failedBatches} 批,耗时 ${(meta.elapsedMs / 1000).toFixed(1)}s);分时 1 分钟线来自腾讯 web.ifzq.gtimg.cn 分时接口(尾盘段 ${segFrom} → ${cutHHMM});行业归属来自本地缓存 eod/data/industry-map.json(东财行业分类)。交易日由行情时间戳众数推导(${meta.marketDate}),不依赖外部交易日历。运行环境:${runner}。`,
|
|
238
|
+
`Market snapshot: Tencent qt.gtimg.cn batch quotes (${meta.slots} slots / ${meta.batches} batches / ${meta.failedBatches} failed, ${(meta.elapsedMs / 1000).toFixed(1)}s); 1-minute bars: Tencent web.ifzq.gtimg.cn (tail segment ${segFrom} → ${cutHHMM}); industry mapping: local cache eod/data/industry-map.json (EM sector classes). Trade date derived from the quote-timestamp mode (${meta.marketDate}); no external calendar. Runner: ${runner}.`);
|
|
239
|
+
|
|
240
|
+
const title = `${cfg.report.title} — ${tradeDate} ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)}`;
|
|
241
|
+
const titleEn = `${cfg.report.titleEn} — ${tradeDate} ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)}`;
|
|
242
|
+
|
|
243
|
+
return `<!DOCTYPE html>
|
|
244
|
+
<html lang="zh-CN">
|
|
245
|
+
<head>
|
|
246
|
+
<meta charset="UTF-8">
|
|
247
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
248
|
+
<title>${title}</title>
|
|
249
|
+
<style>${CSS}</style>
|
|
250
|
+
</head>
|
|
251
|
+
<body>
|
|
252
|
+
<div class="container">
|
|
253
|
+
<div class="topbar">
|
|
254
|
+
<div>
|
|
255
|
+
<h1>${b(cfg.report.title, cfg.report.titleEn)} · ${tradeDate}</h1>
|
|
256
|
+
<div class="sub">${b(`尾盘段 ${segFrom.slice(0, 2)}:${segFrom.slice(2)} → ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)} · 满分 100 · ${modeLabel}`,
|
|
257
|
+
`Tail segment ${segFrom.slice(0, 2)}:${segFrom.slice(2)} → ${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)} · max 100 · ${modeLabel}`)}</div>
|
|
258
|
+
</div>
|
|
259
|
+
<button id="langBtn" type="button">EN</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div class="meta-bar">
|
|
263
|
+
<div><span class="label">${b('交易日:', 'Trade date:')}</span><span class="value">${tradeDate}</span></div>
|
|
264
|
+
<div><span class="label">${b('口径:', 'Cut:')}</span><span class="value">${cutHHMM.slice(0, 2)}:${cutHHMM.slice(2)}</span></div>
|
|
265
|
+
<div><span class="label">${b('快照:', 'Snapshot:')}</span><span class="value">${stats.snapshotCount}</span></div>
|
|
266
|
+
<div><span class="label">${b('初筛通过:', 'Pre-filter pass:')}</span><span class="value">${stats.prePassCount}</span></div>
|
|
267
|
+
<div><span class="label">${b('候选:', 'Candidates:')}</span><span class="value up">${stats.candidateCount}</span></div>
|
|
268
|
+
<div><span class="label">${b('行业组:', 'Groups:')}</span><span class="value">${stats.groupCount}</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(`TOP ${top.length} 候选(分项得分:个股自身 65 + 环境质量 35)`,
|
|
277
|
+
`Top ${top.length} Candidates (breakdown: stock 65 + context 35)`)}</h2>
|
|
278
|
+
${top.length ? `${scoreNote}<div class="table-wrap"><table><thead>${topHead}</thead><tbody>${topRows}</tbody></table></div>`
|
|
279
|
+
: `<div class="card">${b('本时段无满足全部门槛的候选。', 'No candidate passed all gates in this window.')}</div>`}
|
|
280
|
+
|
|
281
|
+
<h2>${b('分行业明细', 'By Industry')}</h2>
|
|
282
|
+
${groupCards || `<div class="card">${b('无分组数据。', 'No group data.')}</div>`}
|
|
283
|
+
|
|
284
|
+
<h2>${b('淘汰漏斗', 'Rejection Funnel')}</h2>
|
|
285
|
+
<div class="card">
|
|
286
|
+
<h3>${b('初筛阶段(快照字段即可判定)', 'Pre-filter (snapshot fields only)')}</h3>
|
|
287
|
+
<div class="table-wrap"><table><thead><tr><th>${b('原因', 'Reason')}</th><th>${b('数量', 'Count')}</th></tr></thead>
|
|
288
|
+
<tbody>${funnelRows || `<tr><td colspan="2">${b('无', 'none')}</td></tr>`}</tbody></table></div>
|
|
289
|
+
<h3 style="margin-top:14px">${b('尾盘段校验阶段(需分时数据)', 'Tail-segment checks (needs minute data)')}</h3>
|
|
290
|
+
<div class="table-wrap"><table><thead><tr><th>${b('原因', 'Reason')}</th><th>${b('数量', 'Count')}</th></tr></thead>
|
|
291
|
+
<tbody>${tailRows || `<tr><td colspan="2">${b('无', 'none')}</td></tr>`}</tbody></table></div>
|
|
292
|
+
${cfg.report.showDataGap && gapRows ? `<details><summary>${b('数据缺口明细(最多 40 条)', 'Data-gap details (up to 40)')}</summary>
|
|
293
|
+
<div class="table-wrap"><table><thead><tr><th>Code</th><th>${b('名称', 'Name')}</th><th>${b('原因', 'Reason')}</th></tr></thead>
|
|
294
|
+
<tbody>${gapRows}</tbody></table></div></details>` : ''}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
<h2>${b('数据口径与偏差声明', 'Data Provenance and Deviations')}</h2>
|
|
298
|
+
<div class="card"><p class="body-text">${provenance}</p></div>
|
|
299
|
+
|
|
300
|
+
<div class="disclaimer">${b('本报告为程序化观察输出,仅供个人研究参考,不构成任何投资建议。尾盘候选按 14:50 快照口径筛选,实际 14:50—15:00 之间价格仍会波动,需自行确认可成交性与流动性。',
|
|
301
|
+
'This report is programmatic observation output for personal research only and is not investment advice. Tail candidates are screened on the 14:50 snapshot; prices still move between 14:50 and 15:00 — verify tradability and liquidity yourself.')}</div>
|
|
302
|
+
<div class="footer">candidate-pool · eod tail screener v${cfg.version} · ${mode === 'observe' ? b('观察模式', 'observe mode') : mode === 'intraday' ? b('盘中模式', 'intraday mode') : b('固定口径', 'fixed cut')} ${cutHHMM}</div>
|
|
303
|
+
</div>
|
|
304
|
+
<script>
|
|
305
|
+
(function () {
|
|
306
|
+
var lang = 'zh';
|
|
307
|
+
var btn = document.getElementById('langBtn');
|
|
308
|
+
var nodes = document.querySelectorAll('[data-zh]');
|
|
309
|
+
nodes.forEach(function (el) {
|
|
310
|
+
if (!el.getAttribute('data-zh')) { el.setAttribute('data-zh', el.innerHTML); }
|
|
311
|
+
});
|
|
312
|
+
function apply(target) {
|
|
313
|
+
nodes.forEach(function (el) {
|
|
314
|
+
var val = el.getAttribute('data-' + target);
|
|
315
|
+
if (val !== null && val !== '') { el.innerHTML = val; }
|
|
316
|
+
});
|
|
317
|
+
document.documentElement.lang = (target === 'zh') ? 'zh-CN' : 'en';
|
|
318
|
+
document.title = (target === 'zh') ? '${title}' : '${titleEn}';
|
|
319
|
+
btn.textContent = (target === 'zh') ? 'EN' : '中文';
|
|
320
|
+
}
|
|
321
|
+
btn.addEventListener('click', function () { lang = (lang === 'zh') ? 'en' : 'zh'; apply(lang); });
|
|
322
|
+
apply(lang);
|
|
323
|
+
})();
|
|
324
|
+
</script>
|
|
325
|
+
</body>
|
|
326
|
+
</html>`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = { buildHTML };
|