功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层

说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
2026-10-05 11:57:13 +08:00
parent 14ec0c6c86
commit cf6d4d2c56
36 changed files with 7385 additions and 285 deletions
+2
View File
@@ -105,6 +105,8 @@ table.data th.l,table.data td.l{text-align:left}
table.data td.num{font-family:var(--mono)}
table.data tbody tr:hover{background:#F1F5F9}
table.data tbody tr:last-child td{border-bottom:none}
/* 已清仓清单:理由很长,截断显示(悬停看全文),免得整张表横向滚动 */
table.data td.reason{max-width:280px;overflow:hidden;text-overflow:ellipsis}
.pager{display:flex;gap:10px;align-items:center;justify-content:flex-end;
margin-top:12px;font-size:12px;color:var(--text-muted)}
+523 -86
View File
@@ -428,7 +428,19 @@ async function viewStock(symbol) {
<div class="card">
<h2>K线 · 股息率 · PE · 回撤</h2>
<div class="toolbar">
<span class="small muted">区间</span>
<input type="date" id="sp-from" style="width:auto">
<span class="small muted">~</span>
<input type="date" id="sp-to" style="width:auto">
<button data-act="sp-apply">应用</button>
<button data-act="sp-reset">重置</button>
<span class="small muted" id="sp-hint"></span>
</div>
<div id="c_main" class="chart tall"></div>
<div class="callout" style="margin-top:12px">
区间只影响这张走势图;右侧分布统计与雷达图仍是<b>画像当时的全历史</b>口径。
</div>
</div>
<div class="grid2">
@@ -452,41 +464,23 @@ async function viewStock(symbol) {
</div>`;
}
const spState = {data: null, from: '', to: ''};
function mountStock(d) {
const ch = d.chart || {};
if (!ch.dates || !ch.dates.length) return;
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
chart('c_main', {
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
axisPointer:{link:[{xAxisIndex:'all'}]},
legend:{top:0,textStyle:{color:'#64748B'}},
grid:[grid(34,190),grid(262,120),grid(420,110)],
xAxis:[
{type:'category',data:ch.dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:ch.dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:ch.dates,gridIndex:2,
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
yAxis:[
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
series:[
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:ch.close,showSymbol:false,
lineStyle:{width:1.4,color:COLORS[0]}},
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:ch.dv_yield,showSymbol:false,
lineStyle:{width:1.6,color:COLORS[1]},areaStyle:{opacity:.12},
markLine:{silent:true,symbol:'none',data:[
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
lineStyle:{color:COLORS[4],type:'dashed'},
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
lineStyle:{color:COLORS[3],type:'dashed'},
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:ch.pe_ttm,showSymbol:false,
lineStyle:{width:1.2,color:COLORS[2]}}]
});
spState.data = d;
spState.from = ''; spState.to = ''; // 换股票回到全区间
const from = document.getElementById('sp-from');
const to = document.getElementById('sp-to');
if (from && to) {
// 画像序列是快照,边界就是能选的全部;不设上限会让人以为能选到今天
from.min = to.min = ch.dates[0];
from.max = to.max = ch.dates[ch.dates.length - 1];
from.value = ch.dates[0];
to.value = ch.dates[ch.dates.length - 1];
}
paintStockMain();
// 直方图(用序列值前端分箱,属于纯呈现,不是业务计算)
const vals = (ch.dv_yield || []).filter(v => v != null && v > 0);
@@ -521,6 +515,63 @@ function mountStock(d) {
}
}
/** 画画像走势图;区间是纯前端截取(画像序列已全量在手,不必再取数)。 */
function paintStockMain() {
const d = spState.data;
const ch = d && d.chart;
if (!ch || !ch.dates || !ch.dates.length) return;
const all = ch.dates;
let i0 = 0, i1 = all.length - 1;
if (spState.from) { const k = all.findIndex(x => x >= spState.from); if (k >= 0) i0 = k; }
if (spState.to) {
for (let i = all.length - 1; i >= 0; i--) { if (all[i] <= spState.to) { i1 = i; break; } }
}
if (i1 < i0) { toast('开始日期不能晚于结束日期', true); return; }
const dates = all.slice(i0, i1 + 1);
const cut = k => ((ch[k] || []).length === all.length ? ch[k].slice(i0, i1 + 1) : []);
const hint = document.getElementById('sp-hint');
if (hint) {
const asof = (d.profile_run && d.profile_run.asof_date) || all[all.length - 1];
hint.textContent = `${dates[0]} ~ ${dates[dates.length - 1]} · ${dates.length} 个交易日`
+ ` · 画像快照止于 ${asof}`;
}
disposeChart('c_main');
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
chart('c_main', {
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
axisPointer:{link:[{xAxisIndex:'all'}]},
legend:{top:0,textStyle:{color:'#64748B'}},
grid:[grid(34,190),grid(262,120),grid(420,110)],
xAxis:[
{type:'category',data:dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
{type:'category',data:dates,gridIndex:2,
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
yAxis:[
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
splitLine:{lineStyle:{color:'#E9EEF6'}}},
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
series:[
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:cut('close'),showSymbol:false,
lineStyle:{width:1.4,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:cut('dv_yield'),showSymbol:false,
lineStyle:{width:1.6,color:COLORS[1]},itemStyle:{color:COLORS[1]},areaStyle:{opacity:.12},
markLine:{silent:true,symbol:'none',data:[
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
lineStyle:{color:COLORS[4],type:'dashed'},
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
lineStyle:{color:COLORS[3],type:'dashed'},
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:cut('pe_ttm'),showSymbol:false,
lineStyle:{width:1.2,color:COLORS[2]},itemStyle:{color:COLORS[2]}}]
});
}
const LABEL = {dv_yield:'股息率',pe_ttm:'PE(TTM)',pb:'PB',ps_ttm:'PS(TTM)',close:'收盘价',
drawdown:'回撤',ttm_dps:'TTM 每股分红',dps:'每股分红',roe:'ROE',roic:'ROIC',
gross_margin:'毛利率',net_margin:'净利率',debt_ratio:'资产负债率',
@@ -609,6 +660,52 @@ async function viewBacktests(params) {
}
/* ---------------- 视图:回测详情 ---------------- */
/** 个股画像闸门:买入信号触发后,用当日可见数据重算画像再逐条核验。 */
function profileGateCard(g) {
const rules = (g && g.rules) || [];
if (!rules.length) return ''; // 该回测没配画像闸门 → 不占版面
const STAT = {current_value: '当日值',
current_percentile: '当日值在窗口分布中的分位'};
const OP = {'>=': '≥', '<=': '≤', '>': '>', '<': '<', '==': '='};
const fmtVal = (metric, v) => {
if (v == null) return '—';
const u = UNIT[metric] || 'ratio';
if (u === 'pct') return pct(v, 2);
if (u === 'int') return num(v, 0) + (metric.endsWith('_years') ? ' 年' : '');
if (u === 'price') return num(v, 2) + ' 元';
return num(v, 2); // ratio:倍数/覆盖
};
const meta = [
`画像窗口 ${num(g.window_years, 0)} 年`,
g.on_unverifiable === 'pass'
? '数据缺失/样本不足时:放行'
: '数据缺失/样本不足时:保守不买(REJECT)',
g.min_window_coverage ? `最小窗口覆盖率 ${pct(g.min_window_coverage, 0)}` : null,
].filter(Boolean).join(' · ');
return `
<div class="card">
<h2>个股画像筛选条件
<span class="badge ${g.enabled ? 'info' : 'dim'}">${g.enabled ? '已启用' : '未启用'}</span></h2>
<div class="callout">
这是<b>买入的第二道闸门</b>:股息率分位触发买入之后,用<b>当日可见的数据</b>
重算一次个股画像,再逐条核验,不通过的票直接剔除
(信号类型 <code class="mono">REJECT</code>,每条规则当时的实际值与阈值可在下方
「未成交信号与原因」里看到)。<b>取不到值不等于通过</b>,按下方「数据缺失」口径处理。
</div>
<div class="toolbar"><span class="small muted">${esc(meta)}</span></div>
<div class="tw"><table class="data">
<thead><tr><th class="l">#</th><th class="l">画像指标</th>
<th class="l">核验口径</th><th class="l">必须满足</th></tr></thead>
<tbody>${rules.map((r, i) => `<tr>
<td class="num">${i + 1}</td>
<td class="l">${esc(LABEL[r.metric] || r.metric)}</td>
<td class="l small">${esc(STAT[r.stat] || r.stat || '—')}</td>
<td class="l mono">${esc(OP[r.op] || r.op || '')} ${esc(fmtVal(r.metric, r.value))}</td>
</tr>`).join('')}</tbody>
</table></div>
</div>`;
}
async function viewBacktestDetail(runId) {
const b = await api(`backtests/${runId}`);
const m = {};
@@ -681,6 +778,18 @@ async function viewBacktestDetail(runId) {
<div id="pf-body"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="closed-card">
<h2>已清仓了结 <span class="badge info" id="closed-count">…</span></h2>
<div class="callout">
期末<b>不再持有</b>的个股(已全部卖出)。<b>卖出后至今</b>是清仓日收盘到最新收盘的涨跌:
<b class="loss">跌</b>说明这笔卖对了,<b class="gain">涨</b>说明可能卖早了。
<b>收益率</b> = 已实现盈亏 ÷ 买入金额,即这笔投资的累计收益率(不是年化;
已清仓,所以全部盈亏都已落袋)。
点代码可打开该股在本次回测中的走势图(默认已经画到最新行情)。
</div>
<div id="closed-body"><div class="loading">加载中…</div></div>
</div>
<div class="card">
<h2>回测条件</h2>
<div class="tw"><table class="data">
@@ -691,6 +800,8 @@ async function viewBacktestDetail(runId) {
${b.strategy.description ? `<div class="callout" style="margin-top:12px">${esc(b.strategy.description)}</div>` : ''}
</div>
${profileGateCard(b.strategy.profile_gate)}
<div class="card">
<h2>逐笔成交与理由 <span class="badge info" id="trade-count">…</span></h2>
<div class="callout">
@@ -700,6 +811,30 @@ async function viewBacktestDetail(runId) {
<div id="trades"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="du-card" style="display:none">
<h2>每日动态股票池 <span class="badge info" id="du-badge">…</span></h2>
<div class="callout">
本模式<b>每个交易日</b>按当时可见数据重建股票池(PIT)。
每个决策日的入选成员都留了痕,可回答「某天为什么是这些股票」。
</div>
<div class="toolbar">
<span class="small muted">决策日</span>
<select id="du-date" style="width:auto"></select>
<span class="small muted" id="du-note"></span>
</div>
<div id="du-body"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="tradeprof-card" style="display:none">
<h2>成交个股的实时画像 <span class="badge info" id="tradeprof-badge">…</span></h2>
<div class="callout">
每一笔成交,都附上<b>该笔决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
这与「个股画像」页的批量画像同一定义,只是按决策日取值 ——
它回答的是「当时凭什么买/卖」。点代码可进入个股页看完整曲线与全部画像指标。
</div>
<div id="tradeprof"><div class="loading">加载中…</div></div>
</div>
<div class="card" id="skips-card" style="display:none">
<h2>未成交信号与原因</h2>
<div class="callout warn">这些信号「想交易但没成交」,用于判断约束是否实质影响绩效。</div>
@@ -824,6 +959,8 @@ async function mountBacktestDetail(runId) {
const t = await api(`backtests/${runId}/trades?size=200`);
document.getElementById('trade-count').textContent = t.total + ' 笔';
// 成交个股的实时画像(仅 daily 模式有留痕;无留痕时整张卡片不显示)
renderTradedProfiles(runId, t.items);
const host = document.getElementById('trades');
if (!t.items.length) { host.innerHTML = '<div class="empty">无成交记录</div>'; }
else {
@@ -845,6 +982,14 @@ async function mountBacktestDetail(runId) {
${t.total > t.items.length ? `<div class="pager">仅显示前 ${t.items.length} / ${t.total} 笔</div>` : ''}`;
}
// 已清仓了结清单(期末不再持有的个股 + 清仓后至今涨跌)
try {
renderClosedPositions(runId, await api(`backtests/${runId}/closed-positions`));
} catch (e) {
const host = document.getElementById('closed-body');
if (host) host.innerHTML = `<div class="callout fail">加载失败:${esc(e.message)}</div>`;
}
try {
const sk = await api(`backtests/${runId}/signals`);
if (sk.items && sk.items.length) {
@@ -860,6 +1005,178 @@ async function mountBacktestDetail(runId) {
</tbody></table></div>`;
}
} catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ }
try { await mountDailyUniverse(runId); }
catch (e) { /* 每日动态股票池只在 --mode daily 存在,失败不影响主流程 */ }
}
/* ---------------- 回测详情:成交个股的实时画像(--mode daily) ---------------- */
/** 把每笔成交的 `reason.profile` 汇总成一张表。
*
* 数据源:`GET /api/backtests/{id}/trades` 的 `items[].reason.profile`
* (由引擎在决策当日写入,不是事后重算)。非 daily 模式或关闭了
* `daily.profile_on_trade` 时没有任何留痕,整张卡片不显示。
*/
/** 已清仓了结清单。清仓复盘真正要回答的是「这笔卖对了没有」。 */
function renderClosedPositions(runId, cp) {
const host = document.getElementById('closed-body');
const badge = document.getElementById('closed-count');
if (!host) return;
const s = cp.summary || {};
if (badge) badge.textContent = `${num(s.count, 0)} 只`;
if (!cp.items || !cp.items.length) {
host.innerHTML = '<div class="empty">本次回测没有已清仓的个股'
+ '(期末仍持有的都在上方「持仓明细」里)</div>';
return;
}
host.innerHTML = `
<div class="toolbar">
<span class="small muted">共 <b>${num(s.count, 0)}</b> 只 · 合计已实现
<b class="${sign(s.realized_pnl)}">${yi(s.realized_pnl)}</b> ·
清仓后<b class="gain">上涨 ${num(s.since_sell_up, 0)}</b> /
<b class="loss">下跌 ${num(s.since_sell_down, 0)}</b> 只
${s.asof ? `· 价格截至 <span class="mono">${esc(s.asof)}</span>` : ''}</span>
</div>
<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th class="l">持仓区间</th><th>持有天数</th>
<th>买入金额</th><th>卖出金额</th>
<th>已实现盈亏</th><th>收益率</th>
<th>清仓日</th><th>卖出后至今</th>
<th class="l">清仓理由</th></tr></thead>
<tbody>${cp.items.map(x => `<tr>
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}"
class="mono">${esc(x.symbol)}</a></td>
<td class="l">${esc(x.name || '—')}</td>
<td class="l small">${esc(x.industry || '—')}</td>
<td class="l num small">${esc(x.first_hold || '—')} ~ ${esc(x.last_hold || '—')}</td>
<td class="num">${num(x.hold_days, 0)}</td>
<td class="num">${num(x.buy_amount, 0)}</td>
<td class="num">${num(x.sell_amount, 0)}</td>
<td class="num ${sign(x.realized_pnl)}">${num(x.realized_pnl, 0)}</td>
<td class="num ${sign(x.return_pct)}">${pct(x.return_pct)}</td>
<td class="num">${esc(x.last_sell || '—')}</td>
<td class="num ${sign(x.since_sell_pct)}">${pct(x.since_sell_pct)}</td>
<td class="l small reason" title="${esc(x.sell_reason || '')}">${esc(x.sell_reason || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderTradedProfiles(runId, items) {
const card = document.getElementById('tradeprof-card');
const host = document.getElementById('tradeprof');
if (!card || !host) return;
const rows = (items || []).filter(t => t.reason && t.reason.profile);
if (!rows.length) return; // 无留痕 → 不显示该卡片
card.style.display = '';
const syms = new Set(rows.map(t => t.symbol));
const badge = document.getElementById('tradeprof-badge');
if (badge) badge.textContent = `${syms.size} 只 / ${rows.length} 笔成交`;
const numOr = (v, u) => (v === null || v === undefined ? '—' : fmtUnit(v, u));
host.innerHTML = `<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th>信号日</th><th>成交日</th><th>方向</th>
<th>股息率</th><th>股息率分位</th><th>PE(TTM)</th><th>PB</th>
<th>ROE(5年均)</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th>
<th>当日池内</th></tr></thead>
<tbody>${rows.map(t => {
const p = t.reason.profile || {};
const v = p.values || {};
const pc = p.percentiles || {};
const inPool = t.reason.in_universe;
return `<tr>
<td class="l"><a class="mono"
href="#/backtests/${esc(runId)}/stocks/${esc(t.symbol)}">${esc(t.symbol)}</a></td>
<td class="num">${esc(t.signal_date)}</td>
<td class="num">${esc(t.execution_date)}</td>
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
<td class="num">${numOr(v.dv_yield, 'pct')}</td>
<td class="num">${pc.dv_yield == null ? '—' : num(pc.dv_yield, 1) + '%'}</td>
<td class="num">${numOr(v.pe_ttm, 'ratio')}</td>
<td class="num">${numOr(v.pb, 'ratio')}</td>
<td class="num">${numOr(v.roe_avg, 'pct')}</td>
<td class="num">${numOr(v.dividend_continuity_years, 'int')} 年</td>
<td class="num">${numOr(v.payout_ratio, 'pct')}</td>
<td class="num">${v.fcf_dividend_cover == null ? '—'
: num(v.fcf_dividend_cover, 2) + 'x'}</td>
<td>${inPool === undefined || inPool === null ? '—'
: (inPool ? '<span class="badge ok">在</span>'
: '<span class="badge warn">已出</span>')}</td></tr>`;
}).join('')}</tbody></table></div>
<div class="callout" style="margin-top:12px">
「当日池内 = 已出」表示该笔发生在持仓<b>掉出当日股票池之后</b>
(<code>pool_exit_action: hold</code> 下只减不加 / 按分位卖出)。
每行的数值都取自该笔成交的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
</div>`;
}
/* ---------------- 回测详情:每日动态股票池(--mode daily) ---------------- */
const duState = {runId: null, timeline: [], date: null};
async function mountDailyUniverse(runId) {
const card = document.getElementById('du-card');
if (!card) return;
const tl = await api(`backtests/${runId}/daily-universe`);
if (!tl.available || !tl.timeline || !tl.timeline.length) return; // 非 daily 模式
duState.runId = runId;
duState.timeline = tl.timeline;
card.style.display = '';
const sizes = tl.timeline.map(x => x.member_count);
const uniq = new Set();
document.getElementById('du-badge').textContent =
`${tl.timeline.length} 个有成员的决策日 · 池内 ${Math.min(...sizes)}~${Math.max(...sizes)} 只`;
const sel = document.getElementById('du-date');
sel.innerHTML = tl.timeline.map(x =>
`<option value="${esc(x.trade_date)}"${x.trade_date === tl.trade_date ? ' selected' : ''}>`
+ `${esc(x.trade_date)}(${x.member_count} 只)</option>`).join('');
sel.addEventListener('change', () => loadDailyUniverse(sel.value));
duState.date = tl.trade_date;
await loadDailyUniverse(duState.date);
}
async function loadDailyUniverse(date) {
const host = document.getElementById('du-body');
const note = document.getElementById('du-note');
if (!host) return;
host.innerHTML = '<div class="loading">加载中…</div>';
const d = await api(`backtests/${duState.runId}/daily-universe?date=${encodeURIComponent(date)}`);
const items = d.members || [];
if (note) {
const row = duState.timeline.find(x => x.trade_date === date) || {};
note.textContent = row.listed_count
? `市场候选 ${row.listed_count} 只 → 实际筛选 ${row.candidate_count ?? '—'} 只 → 入选 ${items.length} 只`
: '';
}
if (!items.length) { host.innerHTML = '<div class="empty">该日无入选成员</div>'; return; }
host.innerHTML = `<div class="tw"><table class="data">
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th>股息率</th><th>PE(TTM)</th><th>PB</th><th>总市值</th>
<th>5年ROE</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th></tr></thead>
<tbody>${items.map(x => {
const v = x.values || {};
return `<tr>
<td class="l mono">${esc(x.symbol)}</td>
<td class="l">${esc(x.name || '—')}</td>
<td class="l small">${esc(x.industry || '—')}</td>
<td class="num">${pct(x.dividend_yield)}</td>
<td class="num">${num(v.pe_ttm)}</td>
<td class="num">${num(v.pb)}</td>
<td class="num">${yi(x.total_mv)}</td>
<td class="num">${pct(x.roe_avg)}</td>
<td class="num">${num(v.dividend_continuity_years,0)} 年</td>
<td class="num">${pct(v.payout_ratio)}</td>
<td class="num">${num(v.fcf_dividend_cover)}x</td></tr>`;
}).join('')}
</tbody></table></div>
<div class="callout" style="margin-top:12px">
「股息率」是<b>筛选口径</b>(<code>universe.yml: yield_source</code>,默认自算 TTM);
PE/PB 来自 <code>daily_basic</code>;5 年 ROE、支付率、FCF 覆盖来自年报(PIT:
只用当时已公告的财报)。
</div>`;
}
/* ---------------- 回测详情:持仓明细 ---------------- */
@@ -958,7 +1275,8 @@ function shiftPortfolio(delta) {
/* ---------------- 视图:回测内个股买卖点 ---------------- */
const stState = {runId: null, symbol: null, data: null, shown: {}};
const stState = {runId: null, symbol: null, data: null, shown: {},
from: '', to: '', seq: 0, rtDone: false};
async function viewBacktestStock(runId, symbol) {
let d;
@@ -994,25 +1312,47 @@ async function viewBacktestStock(runId, symbol) {
<div class="card">
<h2>趋势与买卖点</h2>
<div class="toolbar">
<span class="small muted">区间</span>
<input type="date" id="st-from" style="width:auto">
<span class="small muted">~</span>
<input type="date" id="st-to" style="width:auto">
<button data-act="st-apply">应用</button>
<button data-act="st-reset">重置</button>
<span class="small muted" id="st-range-hint"></span>
</div>
<div class="toolbar">
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
${d.available_series.map(k => `<label class="check">
<input type="checkbox" class="st-ser" value="${k}"
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
<button data-act="st-apply" style="margin-left:auto">应用</button>
</div>
<div id="c_stock" class="chart"></div>
<div id="c_stock_note"></div>
<div class="callout" style="margin-top:12px">
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。<br/>
默认区间 = 首笔成交<b>往前 5 年</b>(股息率分位的判据窗口)→ <b>该股最新行情</b>:
往前留够判据,才能回答「当时凭什么买」;一直画到今天,
才能回答「卖飞了还是卖对了」。区间可自由改。
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
</div>
</div>
<div class="card" id="rt-profile-card">
<h2>决策时点实时画像 <span class="badge info" id="rt-badge">…</span></h2>
<div class="callout">
这是<b>每个买卖决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
与「个股画像」页的批量画像同一定义,但按<b>决策日</b>取值 ——
它回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
数值来自成交记录的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
</div>
<div id="rt-profile"></div>
</div>
<div class="card">
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
${st.trade_count ? `<div class="tw"><table class="data">
@@ -1036,6 +1376,62 @@ async function viewBacktestStock(runId, symbol) {
</div>`;
}
/** 决策时点实时画像:把成交记录里的 reason_json.profile 渲染成表。
*
* 数据来自 `GET /api/backtests/{id}/stocks/{symbol}` 的 trades[].reason.profile,
* 即引擎在决策当日算出的 PIT 画像快照 —— 不是事后重算。
*/
function renderRtProfile(trades) {
const rows = (trades || []).filter(t => t.reason && t.reason.profile);
const host = document.getElementById('rt-profile');
const badge = document.getElementById('rt-badge');
if (!host) return;
if (badge) badge.textContent = rows.length ? rows.length + ' 个决策时点' : '无留痕';
if (!rows.length) {
host.innerHTML = '<div class="empty">本次回测的成交记录里没有画像留痕'
+ '(仅 <code>--mode daily</code> 且 <code>daily.profile_on_trade: true</code> 时记录)</div>';
return;
}
// 优先展示的判据指标(与闸门规则同一批),其余放在展开区
const PRIMARY = ['dv_yield', 'dv_yield_pct', 'pe_ttm', 'pb', 'roe_avg',
'dividend_continuity_years', 'payout_ratio', 'fcf_dividend_cover'];
host.innerHTML = rows.map(t => {
const p = t.reason.profile || {};
const vals = p.values || {};
const pcts = p.percentiles || {};
const cells = PRIMARY.map(k => {
if (k === 'dv_yield_pct') {
return `<td class="num">${pcts.dv_yield == null ? '—' : num(pcts.dv_yield, 1) + '%'}</td>`;
}
const u = UNIT[k] || 'ratio';
return `<td class="num">${fmtUnit(vals[k], u)}</td>`;
}).join('');
// 其余指标(全历史/各窗口的完整快照)折叠展示,避免默认刷屏
const others = Object.keys(vals).filter(k => !PRIMARY.includes(k) && k !== 'dv_yield_pct')
.sort().map(k => `<span class="mono small" style="margin-right:10px">`
+ `${esc(LABEL[k] || k)}=${fmtUnit(vals[k], UNIT[k] || 'ratio')}</span>`).join('');
const gate = t.reason.profile_gate;
const gateTxt = gate
? `<span class="badge ${gate.verdict === 'PASS' ? 'ok' : 'warn'}">闸门 ${esc(gate.verdict)}</span>`
: '';
return `<div style="border-top:1px solid var(--line);padding:10px 0">
<div class="small" style="margin-bottom:6px">
<b>${esc(t.signal_date)}</b>
<span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span>
${gateTxt}
<span class="muted">画像窗口 ${esc(String(p.window_years ?? '—'))} 年 · 决策日 ${esc(p.asof || '—')}</span>
</div>
<div class="tw"><table class="data"><thead><tr>
${PRIMARY.map(k => `<th>${esc(k === 'dv_yield_pct' ? '股息率分位'
: (LABEL[k] || k))}</th>`).join('')}
</tr></thead><tbody><tr>${cells}</tr></tbody></table></div>
${others ? `<details style="margin-top:6px"><summary class="small muted">
展开该决策日的全部 ${Object.keys(vals).length} 项画像指标</summary>
<div style="margin-top:6px">${others}</div></details>` : ''}
</div>`;
}).join('');
}
const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)',
pb: 'PB', roe: 'ROE', drawdown: '回撤'};
// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。
@@ -1067,10 +1463,18 @@ function mountBacktestStock(runId, symbol) {
return;
}
host.innerHTML = '<div class="loading">加载中…</div>';
// 区间留空 = 用后端缺省区间(持仓起点 → 该股最新行情)
const range = (stState.from ? `&start=${encodeURIComponent(stState.from)}` : '')
+ (stState.to ? `&end=${encodeURIComponent(stState.to)}` : '');
const seq = ++stState.seq;
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
+ `?series=${shown.join(',')}`);
+ `?series=${shown.join(',')}${range}`);
if (seq !== stState.seq) return; // 连续改区间时丢掉过期响应
stState.data = d;
// 决策时点实时画像与 K 线无关(不随 series/区间变化),只在本次挂载渲染一次
if (!stState.rtDone) { renderRtProfile(d.trades); stState.rtDone = true; }
host.innerHTML = '';
syncStockRange(d.range);
paintStockPanels(host, d, shown);
};
render().catch(e => {
@@ -1082,6 +1486,24 @@ function mountBacktestStock(runId, symbol) {
return render;
}
/** 把实际生效的区间写回日期框,并把该股行情边界设成 min/max。 */
function syncStockRange(r) {
const from = document.getElementById('st-from');
const to = document.getElementById('st-to');
if (from && to) {
if (r.available_start) { from.min = r.available_start; to.min = r.available_start; }
if (r.available_end) { from.max = r.available_end; to.max = r.available_end; }
from.value = r.start || '';
to.value = r.end || '';
}
const hint = document.getElementById('st-range-hint');
if (hint) {
hint.textContent = `${r.start} ~ ${r.end} · ${r.points} 个交易日`
+ (r.downsampled ? '(已降采样显示)' : '')
+ (r.available_end ? ` · 该股行情至 ${r.available_end}` : '');
}
}
/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */
function paintStockPanels(host, d, shown) {
const N = shown.length;
@@ -1100,20 +1522,9 @@ function paintStockPanels(host, d, shown) {
tradesOn.get(t.execution_date).push(t);
});
// 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天),
// 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴,
// 让股价面板仍能按成交价标出买卖点。
const extraDates = [...new Set(d.trades.map(t => t.execution_date))]
.filter(dt => !dateIndex.has(dt)).sort();
const axisDates = d.dates.slice();
extraDates.forEach(dt => {
let lo = 0, hi = axisDates.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (axisDates[mid] < dt) lo = mid + 1; else hi = mid;
}
axisDates.splice(lo, 0, dt);
});
// 区间外的成交不占横轴类目(用户把区间收窄时,把 2021 年的买卖点塞进
// 2024 年的横轴会把图彻底搞乱),只用下方文字提示它们的存在。
const outside = d.trades.filter(t => !dateIndex.has(t.execution_date));
const titles = [], grids = [], xAxis = [], yAxis = [], series = [];
shown.forEach((k, i) => {
@@ -1122,7 +1533,7 @@ function paintStockPanels(host, d, shown) {
const top = gridTop(i);
grids.push({left: 76, right: 26, top, height: PANEL_H});
xAxis.push({
type: 'category', data: axisDates, gridIndex: i,
type: 'category', data: d.dates, gridIndex: i,
// 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉
boundaryGap: ['1%', '1%'],
axisTick: {show: false},
@@ -1153,28 +1564,22 @@ function paintStockPanels(host, d, shown) {
});
series.push({
name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i,
// 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开)
data: k === 'close' && !extraDates.length
? vals : axisDates.map(dt => {
const j = dateIndex.get(dt);
return j === undefined ? null : vals[j];
}),
showSymbol: false, sampling: 'lttb', z: 5,
data: vals, showSymbol: false, sampling: 'lttb', z: 5,
lineStyle: {width: k === 'close' ? 1.6 : 1.3, color},
itemStyle: {color},
...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}),
});
// 买卖点画在**每个**面板上:取该指标在成交日的取值,
// 这样能直接看出「买在多少股息率 / 多少 PE」。
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致;
// 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致。
[['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8],
['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]]
.forEach(([side, cn, c, sym, size]) => {
const pts = d.trades.filter(t => t.side === side).map(t => {
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
const j = dateIndex.get(t.execution_date);
const v = j === undefined ? null : vals[j];
if (j === undefined) return null; // 区间外:见下方文字提示
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
const v = vals[j];
return v == null ? null : [t.execution_date, v];
}).filter(Boolean);
if (!pts.length) return;
@@ -1229,17 +1634,16 @@ function paintStockPanels(host, d, shown) {
series,
});
// 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过
// 区间外的成交画不出来(横轴上没有那一天),必须说明,
// 否则读者会以为「图上没有卖点 = 这笔没卖过」
const note = document.getElementById('c_stock_note');
if (note) {
const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t =>
`${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、'))
.filter(Boolean);
const items = outside.map(t =>
`${t.execution_date} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`);
note.innerHTML = items.length ? `<div class="callout warn" style="margin-top:12px">
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
${esc(items.join(';'))}。<br/>
这些成交只能按<b>成交价</b>标在「股价」面板上,
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
有 ${items.length} 笔成交不在所选区间(${esc(d.range.start)} ~ ${esc(d.range.end)})内,
因此没有画在图上:${esc(items.join(';'))}。<br/>
把区间放宽(或点「重置」)即可看到这些买卖点。
</div>` : '';
}
}
@@ -1453,7 +1857,11 @@ async function viewArchive() {
}
/* ---------------- 路由 ---------------- */
let currentPath = '';
// 「已渲染路径」缓存。哨兵值必须是 null,**不能是空串**:首页(hash 为空)解析出的
// path 正是 '',若用 '' 表示「还没渲染」,render() 会在首页一开始就
// `path === currentPath` 提前返回 —— 概览页永远停在 index.html 里那句「加载中…」。
// 同理,所有「强制重渲染」的地方一律写 currentPath = null。
let currentPath = null;
function parseHash() {
const raw = location.hash.replace(/^#/, '') || '/';
const [path, qs] = raw.split('?');
@@ -1497,6 +1905,8 @@ async function render() {
else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') {
html = await viewBacktestStock(parts[1], parts[3]);
after = () => { stState.runId = parts[1]; stState.symbol = parts[3];
stState.from = ''; stState.to = ''; // 换股票回到默认区间
stState.rtDone = false; // 换股票要重渲染画像留痕
mountBacktestStock(parts[1], parts[3]); };
}
else if (parts[0] === 'backtests' && parts.length === 2) {
@@ -1545,43 +1955,70 @@ document.addEventListener('click', async e => {
if (scope === 'b' && cur.universe_run_id)
location.hash = `#/universes/${cur.universe_run_id}`;
else if (scope === 'u' && location.hash.includes(id))
{ currentPath=''; render(); }
else { currentPath=''; render(); }
{ currentPath=null; render(); }
else { currentPath=null; render(); }
});
return;
}
if (act === 'archive') {
await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'});
toast(btn.dataset.to === '1' ? '已归档' : '已取消归档');
currentPath = ''; render(); return;
currentPath = null; render(); return;
}
if (act === 'delete') {
const del = btn.dataset.to === '1';
if (del && !confirm('确认删除这条记录?\n\n(软删除:记录会被隐藏,但数据仍完整保留,可随时恢复)')) return;
await patch(`${base}/${id}`, {deleted: del});
toast(del ? '已删除(可从「归档」页恢复)' : '已恢复');
currentPath = ''; render(); return;
currentPath = null; render(); return;
}
if (act === 'pf-shift') {
shiftPortfolio(parseInt(btn.dataset.d, 10));
return;
}
if (act === 'st-apply') {
if (act === 'st-apply' || act === 'st-reset') {
const host = document.getElementById('c_stock');
if (host) {
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
disposeCharts();
const {parts} = parseHash();
mountBacktestStock(parts[1], parts[3]);
if (!host) return;
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
if (act === 'st-reset') {
stState.from = ''; stState.to = '';
} else {
const from = (document.getElementById('st-from') || {}).value || '';
const to = (document.getElementById('st-to') || {}).value || '';
if (from && to && from > to) {
toast('开始日期不能晚于结束日期', true); return;
}
stState.from = from; stState.to = to;
}
disposeCharts();
const {parts} = parseHash();
mountBacktestStock(parts[1], parts[3]);
return;
}
if (act === 'sp-apply' || act === 'sp-reset') {
if (!spState.data) return;
if (act === 'sp-reset') {
spState.from = ''; spState.to = '';
const ch = (spState.data.chart || {});
const from = document.getElementById('sp-from');
const to = document.getElementById('sp-to');
if (from && ch.dates) from.value = ch.dates[0];
if (to && ch.dates) to.value = ch.dates[ch.dates.length - 1];
} else {
const from = (document.getElementById('sp-from') || {}).value || '';
const to = (document.getElementById('sp-to') || {}).value || '';
if (from && to && from > to) { toast('开始日期不能晚于结束日期', true); return; }
spState.from = from; spState.to = to;
}
paintStockMain();
return;
}
if (act === 'page') {
const {parts, q} = parseHash();
q.set('page', btn.dataset.p);
location.hash = `#/${parts.join('/')}?${q.toString()}`;
currentPath = ''; render(); return;
currentPath = null; render(); return;
}
} catch (err) { toast(err.message, true); }
return;
@@ -1595,7 +2032,7 @@ document.addEventListener('click', async e => {
const fEl = document.getElementById('mfilter');
if (fEl) { q.set('passed', fEl.value); q.set('page', '1'); }
location.hash = `#/${parts.join('/')}${q.toString() ? '?' + q.toString() : ''}`;
currentPath = ''; render(); return;
currentPath = null; render(); return;
}
});
@@ -1615,7 +2052,7 @@ document.addEventListener('change', e => {
if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0');
if (e.target.id === 'incDel') q.set('deleted', e.target.checked ? '1' : '0');
location.hash = `#/${parts.join('/')}?${q.toString()}`;
currentPath = ''; render();
currentPath = null; render();
}
});
@@ -1625,7 +2062,7 @@ document.addEventListener('keydown', e => {
}
});
window.addEventListener('hashchange', () => { currentPath = ''; render(); });
window.addEventListener('hashchange', () => { currentPath = null; render(); });
/* ---------------- 启动 ---------------- */
(async function boot() {
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="高股息回测系统">
<rect width="64" height="64" rx="14" fill="#1E40AF"/>
<g fill="#FFFFFF">
<rect x="15" y="36" width="8" height="13" rx="2"/>
<rect x="28" y="27" width="8" height="22" rx="2"/>
<rect x="41" y="16" width="8" height="33" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 363 B

+4
View File
@@ -4,6 +4,10 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>高股息回测系统</title>
<!-- 相对路径:站点挂在 /ggx/ 下时解析为 /ggx/app/favicon.svg。
不声明的话浏览器会自动去请求站点根的 /favicon.ico,而那个位置不归本项目
的 nginx 配置管(每次打开页面都 404,并把错误写进 nginx error_log)。 -->
<link rel="icon" type="image/svg+xml" href="app/favicon.svg">
<link rel="stylesheet" href="app/app.css">
<script src="assets/echarts.min.js"></script>
</head>