/* ============================================================ 高股息回测系统 · 统一前端 前后端分离:本文件只通过 /api/* 读写数据,不做任何指标计算。 路由使用 hash(#/...),因此 nginx 只需托管静态文件 + 反代 /api。 ============================================================ */ 'use strict'; const API = (() => { // 允许把前端挂在与 API 不同的路径下: const meta = document.querySelector('meta[name="api-base"]'); return (meta && meta.content) || 'api'; })(); const COLORS = ['#1E40AF','#D97706','#3B82F6','#059669','#DC2626', '#7C3AED','#0891B2','#CA8A04','#DB2777','#65A30D']; /* ---------------- 工具 ---------------- */ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); // 显示精度来自 config/report.yml: layout.decimals(启动时从 /api/config/display 取)。 // 曾经这里硬编码 2 位,改配置不会生效 —— 与报告层是同一个毛病。 const FMT = {ratio: 4, money: 2, price: 2, percent: 2}; const num = (v, d) => (v === null || v === undefined || Number.isNaN(v)) ? '—' : Number(v).toLocaleString('zh-CN', {minimumFractionDigits: d === undefined ? FMT.ratio : d, maximumFractionDigits: d === undefined ? FMT.ratio : d}); // 百分比小数位 = ratio - 2(比率保留 ratio 位后乘 100,恰好少两位) const pct = (v, d) => (v === null || v === undefined || Number.isNaN(v)) ? '—' : (v * 100).toFixed(d === undefined ? FMT.percent : d) + '%'; const pp = (v, d) => (v === null || v === undefined || Number.isNaN(v)) ? '—' : ((v > 0 ? '+' : '') + (v * 100).toFixed(d === undefined ? FMT.percent : d) + 'pp'); const yi = v => (v === null || v === undefined) ? '—' : (Math.abs(v) >= 1e8 ? (v/1e8).toFixed(1) + ' 亿' : num(v, 0)); const sign = v => (v === null || v === undefined) ? '' : (v > 0 ? 'gain' : (v < 0 ? 'loss' : '')); function toast(msg, isErr) { const t = document.getElementById('toast'); t.textContent = msg; t.className = 'toast show' + (isErr ? ' err' : ''); clearTimeout(t._timer); t._timer = setTimeout(() => { t.className = 'toast'; }, 2600); } async function api(path, opts) { const res = await fetch(`${API}/${path}`, Object.assign({ headers: {'Content-Type': 'application/json'} }, opts)); const text = await res.text(); let data; try { data = text ? JSON.parse(text) : {}; } catch (e) { data = {error: text.slice(0,200)}; } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } const patch = (path, body) => api(path, {method: 'PATCH', body: JSON.stringify(body)}); /* ---------------- 图表管理 ---------------- */ const charts = []; function chart(id, option) { const el = document.getElementById(id); if (!el || typeof echarts === 'undefined') return null; const c = echarts.init(el); c.setOption(Object.assign({color: COLORS}, option)); charts.push(c); return c; } function disposeCharts() { while (charts.length) { try { charts.pop().dispose(); } catch (e) {} } } /** 只销毁一张图(切换叠加指数时重画同一张图,不动页面上其它图)。 */ function disposeChart(id) { const el = document.getElementById(id); if (!el || typeof echarts === 'undefined') return; const inst = echarts.getInstanceByDom(el); if (!inst) return; inst.dispose(); const i = charts.indexOf(inst); if (i >= 0) charts.splice(i, 1); } window.addEventListener('resize', () => charts.forEach(c => c.resize())); /* ---------------- 模态框 ---------------- */ function modal(title, fields, onOk) { const bg = document.createElement('div'); bg.className = 'modal-bg'; bg.innerHTML = ``; document.body.appendChild(bg); const close = () => bg.remove(); bg.addEventListener('click', e => { if (e.target === bg || e.target.dataset.act === 'cancel') close(); if (e.target.dataset.act === 'ok') { const out = {}; fields.forEach(f => { out[f.key] = document.getElementById('f_' + f.key).value.trim(); }); close(); onOk(out); } }); const first = bg.querySelector('input,textarea'); if (first) { first.focus(); first.select(); } } /* ---------------- 视图:概览 ---------------- */ async function viewHome() { const [s, us, bs] = await Promise.all([ api('summary'), api('universes'), api('backtests') ]); document.getElementById('db-info').textContent = s.db || ''; const kpi = (label, value, note, cls) => `
${esc(label)}
${value}
${esc(note)}
`; return `

概览

数据来自 ${esc(s.db)};最近同步 ${esc(s.last_sync || '—')}
${kpi('股票池筛选记录', s.universes_active, `共 ${s.universes} 条(含归档)`)} ${kpi('回测记录', s.backtests_active, `共 ${s.backtests} 条(含归档)`)} ${kpi('最新筛选时点', s.latest_asof || '—', 'Point-in-Time')} ${kpi('Walk-forward', s.walkforwards, '样本外验证运行数')} ${kpi('画像运行', s.profiles, '个股特性画像批次')} ${kpi('参数敏感性', s.sensitivities, '扫描运行数')}

最近的股票池筛选 查看全部 →

${us.items.length ? `
${us.items.slice(0,4).map(recUniverse).join('')}
` : '
还没有筛选记录。
运行 python -m hdiv universe --asof 2024-06-28 创建一条。
'}

最近的回测 查看全部 →

${bs.items.length ? `
${bs.items.slice(0,4).map(recBacktest).join('')}
` : '
还没有回测记录。
运行 python -m hdiv backtest 创建一条。
'}
`; } /* ---------------- 视图:股票池列表 ---------------- */ function recUniverse(u) { return `
${esc(u.title)} ${u.state === 'archived' ? '已归档' : ''} ${u.state === 'deleted' ? '已删除' : ''} ${u.linked_backtests ? `关联 ${u.linked_backtests} 次回测` : ''}
时点 ${esc(u.asof_date)} 候选 ${num(u.candidate_count,0)} 入选 ${num(u.member_count,0)} ${esc((u.created_at||'').slice(0,16))}
${u.notes ? `
备注:${esc(u.notes)}
` : ''}
${u.state === 'deleted' ? `` : ``}
`; } async function viewUniverses(params) { const incArch = params.get('archived') === '1'; const incDel = params.get('deleted') === '1'; const q = params.get('q') || ''; const d = await api(`universes?include_archived=${incArch?1:0}&include_deleted=${incDel?1:0}` + (q ? `&q=${encodeURIComponent(q)}` : '')); return `

股票池筛选记录

每次 hdiv universe 执行产生一条记录。可命名、归档或删除。
「删除」是软删除:记录被隐藏,但数据仍完整保留在数据库中,可随时恢复。 本项目对研究数据坚持只增不删。
${d.items.length ? `
${d.items.map(recUniverse).join('')}
` : '
没有匹配的筛选记录。
'}`; } /* ---------------- 视图:股票池详情 ---------------- */ async function viewUniverseDetail(runId, params) { const passed = params.get('passed'); const page = params.get('page') || 1; const q = params.get('q') || ''; const showAll = passed === 'all'; const query = `passed=${showAll ? 'all' : (passed === '0' ? '0' : '1')}` + `&page=${page}&size=50` + (q ? `&q=${encodeURIComponent(q)}` : ''); const [u, m, bts] = await Promise.all([ api(`universes/${runId}`), api(`universes/${runId}/members?${query}`), api(`universes/${runId}/backtests`) ]); const rows = m.items.map(x => ` ${esc(x.symbol)} ${esc(x.name || '')} ${esc(x.industry || '')} ${pct(x.dividend_yield)} ${x.dividend_continuity_years ?? '—'} ${pct(x.payout_ratio)} ${num(x.fcf_dividend_cover)} ${pct(x.roe)} ${pct(x.debt_ratio)} ${yi(x.total_mv)} ${x.passed ? '入选' : `${esc(x.fail_stage || '')} ${esc(x.fail_reason || '')}`} `).join(''); const stats = u.stats || {}; const stages = ['market','risk','dividend','quality'].map(k => `${k} 淘汰 ${stats[k] ?? '—'}`).join(''); return `
股票池筛选/${esc(u.asof_date)}

${esc(u.title)}

时点 ${esc(u.asof_date)} · 候选 ${num(u.candidate_count,0)} · 入选 ${num(u.member_count,0)} · ${esc(u.run_id)}

记录操作

${u.deleted_at ? `` : ``} ${u.state !== 'active' ? `${u.state === 'archived' ? '已归档':'已删除'}` : ''}
${u.notes ? `
备注:${esc(u.notes)}
` : ''}

筛选漏斗

候选${num(u.candidate_count,0)} 入选${num(u.member_count,0)} 通过率${u.candidate_count ? ((u.member_count/u.candidate_count)*100).toFixed(2)+'%' : '—'}

行业分布 ${(u.industry_distribution||[]).length} 个行业

关联的回测 ${bts.items.length}

${bts.items.length ? `
${bts.items.map(b => recBacktest(b, true)).join('')}
` : `
这条筛选记录还没有关联回测。
运行 python -m hdiv backtest --universe-run ${esc(runId)}, 或在下方「回测」页把已有回测关联过来。
`}

股票清单 ${num(m.total,0)}

${rows || ''}
代码名称行业 股息率连续分红支付率FCF覆盖 ROE负债率总市值结果 / 未通过原因
无数据
第 ${m.page} / ${m.pages || 1} 页,共 ${num(m.total,0)} 条
${stages}

生效的筛选配置

展开完整 JSON
${esc(JSON.stringify(u.config, null, 2))}
`; } /* 股票池详情的图表:数据由后端算好,前端只渲染 */ function mountUniverseDetail(u) { const f = u.funnel; if (f && f.labels && f.labels.length) { // 横向条形:候选(数千只)会压倒其余各段,这是事实本身 // (92% 在市值门槛就被淘汰)。因此在条末直接标数值,保证小段也可读。 const rows = f.labels.map((l, i) => ({ name: l, value: f.values[i], eliminated: i === 0 ? null : f.eliminated[i - 1] })).reverse(); chart('u_funnel', { tooltip: { trigger: 'item', formatter: p => { const d = rows[p.dataIndex]; return `${d.name}
存活 ${d.value.toLocaleString()}` + (d.eliminated != null ? `
本环节淘汰 ${d.eliminated.toLocaleString()}` : ''); } }, grid: {left: 74, right: 76, top: 12, bottom: 24}, xAxis: {type: 'value', axisLabel: {color: '#64748B'}, splitLine: {lineStyle: {color: '#E9EEF6'}}}, yAxis: {type: 'category', data: rows.map(r => r.name), axisLabel: {color: '#64748B'}, axisTick: {show: false}}, series: [{ type: 'bar', barMaxWidth: 22, data: rows.map(r => r.value), itemStyle: {color: p => COLORS[Math.min(p.dataIndex, COLORS.length - 1)]}, label: {show: true, position: 'right', color: '#0F172A', fontSize: 11, formatter: p => p.value.toLocaleString()} }] }); } const dist = (u.industry_distribution || []).slice(0, 12); if (dist.length) { chart('u_industry', { tooltip: {trigger: 'item', formatter: p => `${p.name}
${p.value} 只(${p.percent}%)`}, grid: {left: 92, right: 60, top: 10, bottom: 24}, xAxis: {type: 'value', axisLabel: {color: '#64748B'}, splitLine: {lineStyle: {color: '#E9EEF6'}}}, yAxis: {type: 'category', data: dist.map(d => d.industry).reverse(), axisLabel: {color: '#64748B'}, axisTick: {show: false}}, series: [{ type: 'bar', barMaxWidth: 18, data: dist.map(d => d.count).reverse(), itemStyle: {color: COLORS[0]}, label: {show: true, position: 'right', color: '#0F172A', fontSize: 11} }] }); } } /* ---------------- 视图:个股画像 ---------------- */ async function viewStock(symbol) { let d; try { d = await api(`stocks/${encodeURIComponent(symbol)}`); } catch (e) { return `
返回
无法打开画像:${esc(e.message)}
`; } const info = d.info || {}; const ys = d.yield_summary || {}; const cur = ys.current_value, cp = ys.current_percentile; const statRows = (d.stats || []).map(s => { const u = UNIT[s.metric_code] || 'ratio'; const f = v => fmtUnit(v, u); return ` ${esc(LABEL[s.metric_code] || s.metric_code)} ${s.window_years == 0 ? '全历史' : s.window_years + '年'} ${num(s.n_obs,0)} ${f(s.min_value)}${f(s.p10)} ${f(s.p25)}${f(s.p50)} ${f(s.p75)}${f(s.p90)} ${f(s.max_value)} ${f(s.current_value)} ${num(s.current_percentile,1)}% ${esc(s.status)} `; }).join(''); const scoreRows = (d.scores || []).map(s => ` ${esc(SCORE_LABEL[s.score_code] || s.score_code)} ${num(s.score,1)}`).join(''); return `
概览/个股画像

${esc(info.name || symbol)} ${esc(symbol)}

${esc(info.industry || '—')} · ${esc(info.market || '')} · 上市 ${esc(info.list_date || '—')} · 画像时点 ${esc((d.profile_run && d.profile_run.asof_date) || '—')}
当前股息率
${pct(cur)}
TTM 每股分红 / 不复权收盘价
历史分位
${num(cp,1)}%
当前值在自身历史分布中的位置
P75 / P25
${pct(ys.p75)} / ${pct(ys.p25)}
买入 / 卖出阈值参考
样本观测
${num(ys.n_obs,0)}
日频观测数

K线 · 股息率 · PE · 回撤

股息率历史分布

安全边际分项得分

${scoreRows ? `
${scoreRows}
分项得分
` : '
分项数据不足,未计算得分(不使用残缺数据造分)。
'}

历史分布统计

${statRows || ''}
指标窗口样本 MinP10P25P50P75P90Max 当前分位状态
无数据
`; } 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]}}] }); // 直方图(用序列值前端分箱,属于纯呈现,不是业务计算) const vals = (ch.dv_yield || []).filter(v => v != null && v > 0); if (vals.length) { const lo = Math.min(...vals), hi = Math.max(...vals), N = 12; const w = (hi - lo) / N || 1; const counts = new Array(N).fill(0); vals.forEach(v => counts[Math.min(N-1, Math.floor((v - lo) / w))]++); const labels = counts.map((_, i) => ((lo + w*(i+0.5))*100).toFixed(2) + '%'); chart('c_hist', { tooltip:{trigger:'axis'}, grid:{left:56,right:20,top:20,bottom:44}, xAxis:{type:'category',data:labels,axisLabel:{color:'#64748B',rotate:40,fontSize:10}}, yAxis:{type:'value',name:'天数',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}, series:[{type:'bar',data:counts,itemStyle:{color:COLORS[0]},barMaxWidth:24}] }); } if (d.scores && d.scores.length) { const ind = d.scores.filter(s => s.score != null && s.score_code !== 'composite') .map(s => ({name: SCORE_LABEL[s.score_code] || s.score_code, max: 100})); const val = d.scores.filter(s => s.score != null && s.score_code !== 'composite') .map(s => Number(s.score.toFixed(1))); if (ind.length >= 3) { chart('c_score', { tooltip:{}, radar:{indicator:ind,axisName:{color:'#64748B',fontSize:11}, splitLine:{lineStyle:{color:'#E9EEF6'}},splitArea:{areaStyle:{color:['#fff','#F8FAFC']}}}, series:[{type:'radar',data:[{value:val,name:'安全边际',areaStyle:{opacity:.2}, lineStyle:{color:COLORS[0]},itemStyle:{color:COLORS[0]}}]}] }); } } } 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:'资产负债率', ocf_to_profit:'经营现金流/净利润',payout_ratio:'分红支付率', fcf_dividend_cover:'FCF 对分红覆盖',dividend_continuity_years:'连续分红年数', dividend_years_in_window:'窗口内分红年数',dps_cagr_5y:'DPS 5年复合增速', dps_volatility:'DPS 波动率',free_cashflow:'自由现金流',total_cash_dividend:'总现金分红', vol_250d:'年化波动率',max_drawdown_3y:'3年最大回撤',beta_300:'Beta(沪深300)', avg_amount_20d:'20日均成交额',ret_1y:'近1年收益',ret_3y:'近3年收益', ret_5y:'近5年收益',cagr_5y:'5年复合收益',roe_avg:'ROE(5年均)',roic_avg:'ROIC(5年均)', gross_margin_avg:'毛利率(5年均)',net_margin_avg:'净利率(5年均)', ocf_to_profit_avg:'经营现金流/净利润(5年均)',dv_vol_daily:'股息率日频波动', dv_vol_monthly:'股息率月频波动',dv_vol_quarterly:'股息率季频波动', dv_vol_annual:'股息率年频波动',n_income_attr_p:'归母净利润',total_assets:'总资产'}; const UNIT = {dv_yield:'pct',drawdown:'pct',roe:'pct',roic:'pct',gross_margin:'pct', net_margin:'pct',debt_ratio:'pct',payout_ratio:'pct',dps_cagr_5y:'pct', vol_250d:'pct',max_drawdown_3y:'pct',ret_1y:'pct',ret_3y:'pct',ret_5y:'pct', cagr_5y:'pct',roe_avg:'pct',roic_avg:'pct',gross_margin_avg:'pct', net_margin_avg:'pct',dv_vol_daily:'ratio',dv_vol_monthly:'ratio', dv_vol_quarterly:'ratio',dv_vol_annual:'ratio',dividend_continuity_years:'int', dividend_years_in_window:'int',avg_amount_20d:'money',free_cashflow:'money', total_cash_dividend:'money',n_income_attr_p:'money',total_assets:'money', close:'price',dps:'price',ttm_dps:'price'}; const SCORE_LABEL = {dividend_yield:'股息率安全边际',valuation:'估值安全边际', financial_quality:'财务质量',balance_sheet:'资产负债表', dividend_quality:'分红质量',composite:'综合得分'}; function fmtUnit(v, u) { if (v === null || v === undefined) return '—'; if (u === 'pct') return pct(v); if (u === 'money') return yi(v); if (u === 'int') return num(v, 0); return num(v, u === 'price' ? 2 : 4); } /* ---------------- 视图:回测列表 ---------------- */ function recBacktest(b, compact) { const m = b.metrics || {}; return `
${esc(b.title)} ${b.state === 'archived' ? '已归档' : ''} ${b.state === 'deleted' ? '已删除' : ''} ${b.mode && b.mode !== 'single' ? `${esc(b.mode)}` : ''}
${compact ? '' : `
${esc(b.start_date)} ~ ${esc(b.end_date)} ${esc((b.created_at||'').slice(0,16))} ${b.universe_run_id ? `← 来源股票池` : ''}
`} ${(b.strategy && b.strategy.conditions) ? `
${b.strategy.conditions.map(c => `${esc(c.group)}:${esc(c.text)}`).join('')}
` : ''} ${m.total_return != null ? `
总收益${pct(m.total_return)} CAGR${pct(m.cagr)} 最大回撤${pct(m.max_drawdown)} Sharpe${num(m.sharpe)} 成交${num(m.trade_count,0)}
` : ''}
${b.deleted_at ? `` : ``}
`; } async function viewBacktests(params) { const incArch = params.get('archived') === '1'; const d = await api(`backtests?include_archived=${incArch?1:0}`); return `

回测记录

每条记录显示回测条件与总体结果。点击「打开详细结果」查看净值、指标与逐笔成交。
${d.items.length ? `
${d.items.map(b => recBacktest(b)).join('')}
` : `
还没有回测记录。
运行 python -m hdiv backtest 创建一条。
`}`; } /* ---------------- 视图:回测详情 ---------------- */ async function viewBacktestDetail(runId) { const b = await api(`backtests/${runId}`); const m = {}; (await api(`backtests/${runId}/metrics`)).items .filter(x => x.scope === 'all' && (!x.benchmark_code || x.benchmark_code === '')) .forEach(x => { m[x.metric_code] = x.metric_value; }); const rc = b.reconciliation || {}; return `
回测记录/${esc(b.strategy_id)}

${esc(b.title)}

${esc(b.start_date)} ~ ${esc(b.end_date)} · 模式 ${esc(b.mode)} · ${esc(b.run_id)} ${b.universe_run_id ? ` · 来源股票池 ${esc(b.universe_run_id)}` : ''}
总收益
${pct(m.total_return)}
期初 ${yi(b.initial_capital)} → 期末 ${yi(b.final_capital)}
年化 CAGR
${pct(m.cagr)}
区间 ${num(m.years,2)} 年
最大回撤
${pct(m.max_drawdown)}
相对历史高点
Sharpe
${num(m.sharpe)}
Sortino ${num(m.sortino)} · Calmar ${num(m.calmar)}
成交笔数
${num(m.trade_count,0)}
胜率 ${pct(m.win_rate)} · 平均持仓 ${num(m.avg_holding_days,0)} 天
累计现金分红
${yi(b.total_dividend_net)}
已扣红利税 ${yi(b.total_dividend_tax)}
资金对账${rc.balanced ? '通过' : '未通过'} —— 恒等式「期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红」残差 ${num(rc.residual, 4)} 元。 ${rc.balanced ? '成本与分红入账完整无遗漏。' : '此时不应采信上方绩效指标。'}

净值曲线与基准

叠加指数(右轴)
右轴叠加的是所选指数的点位(不归一化),左轴是净值(起点 = 1); 虚线「基准净值」是本次回测自带的基准,已归一到 1,可直接与策略净值比高低。 指数默认沪深300,可在上方下拉切换或取消叠加。

持仓明细 …

日期
加载中…

回测条件

${(b.strategy.conditions||[]).map(c => ``).join('')}
类别条件
${esc(c.group)}${esc(c.text)}
${b.strategy.description ? `
${esc(b.strategy.description)}
` : ''}

逐笔成交与理由 …

每笔交易都记录了触发理由(当时的股息率、历史分位、Rule 与参照样本数), 可回答「为什么买」。点击行首展开完整 reason。
加载中…

可复现性

策略${esc(b.strategy_id)} v${esc(b.strategy_version)}
配置指纹 config_hash${esc(b.config_hash||'—')}
数据版本 data_version${esc(b.data_version||'—')}
代码版本 code_version${esc(b.code_version||'—')}
随机种子 seed${esc(b.seed ?? '—')}
${(b.unimplemented && b.unimplemented.length) ? `
未建模部分(如实声明):
` : ''}
`; } const eqState = {runId: null, index: '', indices: []}; /** 净值曲线 + 可选的指数叠加(指数画在右轴,单位是点位,不参与净值刻度)。 */ async function mountEquityChart(runId, indexCode) { const host = document.getElementById('c_eq'); if (!host) return; disposeChart('c_eq'); const eq = await api(`backtests/${runId}/equity` + (indexCode ? `?index=${encodeURIComponent(indexCode)}` : '')); const note = document.getElementById('eq-index-note'); if (!eq.dates || !eq.dates.length) { host.innerHTML = '
该回测没有净值曲线
'; if (note) note.textContent = ''; return; } host.innerHTML = ''; disposeChart('c_eq'); // 连续切换下拉时,上一次请求可能已经 init 过 const ix = eq.index; const isNav = n => n === '策略净值' || n === '基准净值'; // itemStyle 必须跟着写:图例与提示框的圆点取 itemStyle(缺省按调色板序号取色), // 只写 lineStyle 会让「回撤」的圆点变成浅蓝、指数圆点变成绿色,与线色对不上。 const series = [ {name:'策略净值',type:'line',data:eq.nav,showSymbol:false, lineStyle:{width:2,color:COLORS[0]},itemStyle:{color:COLORS[0]}}, {name:'基准净值',type:'line',data:eq.bench,showSymbol:false, lineStyle:{width:1.4,color:COLORS[1],type:'dashed'},itemStyle:{color:COLORS[1]}}, {name:'回撤',type:'line',data:eq.drawdown,showSymbol:false, lineStyle:{width:1,color:COLORS[4]},itemStyle:{color:COLORS[4]}, areaStyle:{opacity:.1}}, ]; if (ix) { series.push({ name:`${ix.name}(右轴)`, type:'line', yAxisIndex:1, data:ix.close, showSymbol:false, connectNulls:true, // 指数偶有缺日,不因此断线 // 用紫色:与策略净值的深蓝、基准的橙虚线、回撤的红都拉开 lineStyle:{width:1.6,color:COLORS[5]}, itemStyle:{color:COLORS[5]}, }); } // 净值 / 回撤 / 指数点位三种量纲,不能用同一个小数位 const rowFmt = p => { const v = p.value; const text = v == null ? '—' : p.seriesName === '回撤' ? pct(v, 2) : isNav(p.seriesName) ? num(v, 4) : num(v, 2) + ' 点'; return `
${p.marker}${esc(p.seriesName)} ${text}
`; }; chart('c_eq', { tooltip:{trigger:'axis',axisPointer:{type:'cross'}, formatter: ps => `
` + `${esc(ps.length ? ps[0].axisValue : '')}
${ps.map(rowFmt).join('')}`}, legend:{top:0,textStyle:{color:'#64748B'}}, grid:{left:66,right:ix?62:30,top:38,bottom:56}, xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B', formatter:v=>String(v).slice(0,7)}}, yAxis:[ {type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'}, splitLine:{lineStyle:{color:'#E9EEF6'}}}, ...(ix ? [{type:'value',scale:true,name:'指数点位',position:'right', axisLabel:{color:'#64748B',formatter:v=>num(v,0)}, nameTextStyle:{color:'#64748B'},splitLine:{show:false}}] : []), ], dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}], series, }); if (note) { if (!ix) note.textContent = ''; else if (!ix.covered) note.innerHTML = '该指数在本次回测区间内没有行情'; else note.textContent = `${ix.name} ${ix.code} · 覆盖 ${ix.points}/${eq.dates.length} 个交易日`; } } async function mountBacktestDetail(runId) { // 持仓面板与净值曲线并行加载:持仓查询较慢,不想拖住曲线 pfState.runId = runId; loadPortfolio(null); // 指数下拉:拿不到列表也不该挡住净值曲线本身 eqState.runId = runId; try { eqState.indices = (await api('indices')).items || []; } catch (e) { eqState.indices = []; } const def = eqState.indices.find(x => x.is_default) || eqState.indices[0]; eqState.index = def ? def.code : ''; const sel = document.getElementById('eq-index'); if (sel) { sel.innerHTML = eqState.indices.map(x => ``).join('') + ``; } await mountEquityChart(runId, eqState.index); const t = await api(`backtests/${runId}/trades?size=200`); document.getElementById('trade-count').textContent = t.total + ' 笔'; const host = document.getElementById('trades'); if (!t.items.length) { host.innerHTML = '
无成交记录
'; } else { host.innerHTML = `
${t.items.map((x,i)=>``).join('')}
#代码信号日成交日 方向价格数量金额费用 已实现盈亏持仓天数触发理由
${i+1} ${esc(x.symbol)} ${esc(x.signal_date)}${esc(x.execution_date)} ${esc(x.side)} ${num(x.price,3)}${num(x.quantity,0)} ${num(x.amount,0)}${num(x.total_cost,2)} ${x.realized_pnl==null?'—':num(x.realized_pnl,0)} ${x.holding_days ?? '—'} ${esc(x.reason_text)}
${t.total > t.items.length ? `
仅显示前 ${t.items.length} / ${t.total} 笔
` : ''}`; } try { const sk = await api(`backtests/${runId}/signals`); if (sk.items && sk.items.length) { document.getElementById('skips-card').style.display = ''; document.getElementById('skips').innerHTML = `
${sk.items.map(s=>``).join('')}
代码信号日类型分位 未成交原因
${esc(s.symbol)}${esc(s.signal_date)} ${esc(s.signal_type)} ${num(s.yield_percentile,1)}% ${esc(s.skip_label || '—')}
`; } } catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ } } /* ---------------- 回测详情:持仓明细 ---------------- */ const pfState = {runId: null, dates: [], cursor: null, loadedFor: null}; // 日期清单在进入回测时取一次即可 —— 曾在每次切日期时重复拉取, // 单次 460KB,翻页几下就很浪费。 async function ensurePfDates(runId) { if (pfState.loadedFor === runId && pfState.dates.length) return pfState.dates; const d = await api(`backtests/${runId}/position-dates`); pfState.dates = d.dates || []; pfState.loadedFor = runId; return pfState.dates; } async function loadPortfolio(date) { const host = document.getElementById('pf-body'); if (!host) return; host.innerHTML = '
加载中…
'; try { const q = date ? `?date=${encodeURIComponent(date)}` : ''; const d = await api(`backtests/${pfState.runId}/portfolio${q}`); await ensurePfDates(pfState.runId); pfState.cursor = d.date; const el = document.getElementById('pf-date'); if (el) el.value = d.date; const badge = document.getElementById('pf-badge'); if (badge) badge.textContent = d.adjusted ? `请求 ${d.requested_date} → 最近交易日 ${d.date}` : d.date; const rng = document.getElementById('pf-range'); if (rng) rng.textContent = `可选区间 ${d.range.start} ~ ${d.range.end}(${d.range.count} 个交易日)`; const e = d.equity, sm = d.summary; const rows = d.positions.map((x, i) => ` ${i + 1} ${esc(x.symbol)} ${esc(x.name || '')} ${esc(x.industry || '')} ${num(x.quantity, 0)} ${num(x.avg_cost, 3)} ${num(x.close, 3)} ${num(x.market_value, 0)} ${pct(x.weight)} ${num(x.unrealized_pnl, 0)} ${pct(x.pnl_pct)} ${x.holding_days ?? '—'} `).join(''); host.innerHTML = `
总资产
${yi(e.total_value)}
净值 ${num(e.nav, 4)}
现金
${yi(e.cash)}
占比 ${pct(e.total_value ? e.cash / e.total_value : null)}
持仓市值
${yi(e.position_value)}
${sm.count} 只
浮动盈亏
${yi(sm.unrealized_pnl)}
成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}
当日涨跌
${pct(e.daily_return)}
累计 ${pct(e.cum_return)}
回撤
${pct(e.drawdown)}
相对历史高点
${d.positions.length ? `
${rows}
#代码名称行业 股数成本收盘市值权重 浮动盈亏收益率持仓天数
合计 ${num(sm.market_value, 0)} ${pct(e.total_value ? sm.market_value / e.total_value : null)} ${num(sm.unrealized_pnl, 0)} ${pct(sm.unrealized_pnl_pct)}
` : '
该日空仓(100% 现金)
'}
点击任意股票代码可打开该股在本次回测中的买卖点与趋势图。
`; } catch (err) { host.innerHTML = `
加载失败:${esc(err.message)}
`; } } function shiftPortfolio(delta) { if (!pfState.dates.length) return loadPortfolio(null); if (delta === 0) return loadPortfolio(null); const i = pfState.dates.indexOf(pfState.cursor); const j = Math.max(0, Math.min(pfState.dates.length - 1, (i < 0 ? pfState.dates.length - 1 : i) + delta)); loadPortfolio(pfState.dates[j]); } /* ---------------- 视图:回测内个股买卖点 ---------------- */ const stState = {runId: null, symbol: null, data: null, shown: {}}; async function viewBacktestStock(runId, symbol) { let d; try { d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`); } catch (e) { return `
返回回测
无法加载:${esc(e.message)}
`; } const st = d.stats; const i = d.info; return `
回测/ ${esc(runId.slice(0, 12))}…/个股

${esc(i.name || symbol)} ${esc(symbol)}

${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)} ${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}
成交笔数
${st.trade_count}
买 ${st.buy_count} / 卖 ${st.sell_count}
买入金额
${yi(st.buy_amount)}
卖出 ${yi(st.sell_amount)}
已实现盈亏
${yi(st.realized_pnl)}
仅平仓部分
交易费用
${num(st.total_fees, 2)}
佣金+印花税+过户费
首笔 / 末笔
${esc(st.first_trade || '—')}
${esc(st.last_trade || '—')}
成交日期

趋势与买卖点

显示指标(勾几项就是几联图,自上而下排列): ${d.available_series.map(k => ``).join('')}
每个指标独占一个面板(N 联图):时间轴、缩放与十字光标上下联动, 便于对照同一时点的估值与质量读数。▲ 买入 / ▼ 卖出 同时标注在每个面板上, 位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。 股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价), ROE 按公告日对齐成阶梯线(不插值,避免未来函数)。

逐笔成交明细 ${st.trade_count}

${st.trade_count ? `
${d.trades.map((t, i2) => ``).join('')}
#信号日成交日方向 价格股数金额佣金印花税过户费 费用合计滑点成本已实现盈亏持仓天数 触发理由
${i2 + 1} ${esc(t.signal_date)}${esc(t.execution_date)} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)}${num(t.quantity, 0)} ${num(t.amount, 0)} ${num(t.commission, 2)}${num(t.stamp_tax, 2)} ${num(t.transfer_fee, 2)}${num(t.total_cost, 2)} ${num(t.slippage_cost, 2)} ${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)} ${t.holding_days ?? '—'} ${esc(t.reason_text)}
` : '
该股在本次回测中没有成交
'}
`; } const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)', pb: 'PB', roe: 'ROE', drawdown: '回撤'}; // 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。 // 顺序固定而非按勾选先后,避免同一组指标因勾选次序不同而换位置。 const SERIES_ORDER = ['close', 'dv_yield', 'pe_ttm', 'pb', 'roe', 'drawdown']; const SERIES_UNIT = {close: '元', dv_yield: '', pe_ttm: '倍', pb: '倍', roe: '', drawdown: ''}; // 轴刻度 / 十字光标标签:比率型指标(股息率、回撤)在此转为 % const SERIES_TICK = {close: v => num(v, 2), dv_yield: v => (v * 100).toFixed(2) + '%', pe_ttm: v => num(v, 1), pb: v => num(v, 1), roe: v => num(v, 1) + '%', drawdown: v => (v * 100).toFixed(0) + '%'}; // 提示框数值(带单位) const SERIES_TEXT = {close: v => num(v, 2) + ' 元', dv_yield: v => pct(v, 2), pe_ttm: v => num(v, 2), pb: v => num(v, 2), roe: v => num(v, 2) + '%', drawdown: v => pct(v, 2)}; function mountBacktestStock(runId, symbol) { const render = async () => { const host = document.getElementById('c_stock'); if (!host) return; const shown = [...document.querySelectorAll('.st-ser:checked')] .map(x => x.value) .sort((a, b) => SERIES_ORDER.indexOf(a) - SERIES_ORDER.indexOf(b)); if (!shown.length) { host.style.height = '140px'; host.innerHTML = '
请至少勾选一个指标
'; const n0 = document.getElementById('c_stock_note'); if (n0) n0.innerHTML = ''; return; } host.innerHTML = '
加载中…
'; const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}` + `?series=${shown.join(',')}`); stState.data = d; host.innerHTML = ''; paintStockPanels(host, d, shown); }; render().catch(e => { const host = document.getElementById('c_stock'); if (host) host.innerHTML = `
图表加载失败:${esc(e.message)}
`; const note = document.getElementById('c_stock_note'); if (note) note.innerHTML = ''; }); return render; } /** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */ function paintStockPanels(host, d, shown) { const N = shown.length; const TOP = 46; // 顶部:买卖点图例 + 首个面板标题 const GAP = 34; // 面板间距(容纳下一个面板标题) const BOTTOM = 54; // 末面板的 x 轴标签 + dataZoom 滑条 const PANEL_H = N <= 2 ? 170 : (N <= 4 ? 132 : 112); const gridTop = i => TOP + i * (PANEL_H + GAP); host.style.height = (gridTop(N - 1) + PANEL_H + BOTTOM) + 'px'; const dateIndex = new Map(d.dates.map((dt, i) => [dt, i])); const idxOf = date => (dateIndex.has(date) ? dateIndex.get(date) : -1); const tradesOn = new Map(); d.trades.forEach(t => { if (!tradesOn.has(t.execution_date)) tradesOn.set(t.execution_date, []); 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); }); const titles = [], grids = [], xAxis = [], yAxis = [], series = []; shown.forEach((k, i) => { const color = COLORS[i % COLORS.length]; const vals = d.series[k] || []; const top = gridTop(i); grids.push({left: 76, right: 26, top, height: PANEL_H}); xAxis.push({ type: 'category', data: axisDates, gridIndex: i, // 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉 boundaryGap: ['1%', '1%'], axisTick: {show: false}, axisLine: {show: i === N - 1, lineStyle: {color: '#DBEAFE'}}, // 只有最下面的面板显示日期,其余靠十字光标对齐读取 axisLabel: {show: i === N - 1, color: '#64748B', fontSize: 11, formatter: v => String(v).slice(0, 7)}, axisPointer: {label: {show: i === N - 1}}, }); yAxis.push({ // scale:true 让轴随数据取值;splitNumber 不能太小 —— 取 3 时 // 「nice」刻度会把价格轴整到 0~120,趋势被压扁(实测过)。 type: 'value', gridIndex: i, scale: true, splitNumber: 4, axisLabel: {color: '#64748B', fontSize: 11, formatter: SERIES_TICK[k]}, splitLine: {lineStyle: {color: '#E9EEF6'}}, axisPointer: {label: {formatter: p => SERIES_TICK[k](p.value)}}, }); titles.push({ left: 76, top: top - 20, text: `{n|${SERIES_LABEL[k] || k}}` + (SERIES_UNIT[k] ? `{u|(${SERIES_UNIT[k]})}` : '') + `{v|最新 ${SERIES_TEXT[k](vals[vals.length - 1])}}`, textStyle: {rich: { n: {fontSize: 12, fontWeight: 600, color}, u: {fontSize: 11, color: '#94A3B8'}, v: {fontSize: 11, color: '#94A3B8', padding: [0, 0, 0, 10]}, }}, }); 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, 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]; return v == null ? null : [t.execution_date, v]; }).filter(Boolean); if (!pts.length) return; series.push({ name: cn, type: 'scatter', xAxisIndex: i, yAxisIndex: i, z: 20, symbol: sym, symbolSize: size, itemStyle: {color: c}, data: pts, tooltip: {show: false}, // 统一由 axis 提示框呈现 }); }); }); const xIdx = shown.map((_, i) => i); chart('c_stock', { animation: false, // 任意面板悬停都弹同一份「全指标 + 当日成交」读数 tooltip: { trigger: 'axis', confine: true, axisPointer: {type: 'cross', label: {backgroundColor: '#475569'}}, formatter: params => { const p = Array.isArray(params) ? params[0] : params; if (!p) return ''; const date = p.axisValue, idx = idxOf(date); const head = `
${esc(date)}
`; const rows = idx < 0 ? `
该日超出指标数据区间,仅此处的成交记录
` : shown.map((k, i) => `
● ${esc(SERIES_LABEL[k] || k)} ${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))}
`).join(''); const trs = (tradesOn.get(date) || []).map(t => `
${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'} ${num(t.price, 3)} 元 · ${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元
${esc(t.reason_text)}
`).join(''); return `
${head}${rows}${trs}
`; }, }, legend: {data: ['买入', '卖出'], top: 2, right: 8, itemWidth: 12, itemHeight: 8, itemGap: 14, textStyle: {color: '#64748B', fontSize: 11}}, axisPointer: {link: [{xAxisIndex: 'all'}]}, // 十字光标跨面板对齐 title: titles, grid: grids, xAxis, yAxis, dataZoom: [{type: 'inside', xAxisIndex: xIdx}, {type: 'slider', xAxisIndex: xIdx, height: 16, bottom: 10, borderColor: '#DBEAFE', fillerColor: 'rgba(30,64,175,.08)', handleStyle: {color: COLORS[0]}, textStyle: {color: '#64748B', fontSize: 10}}], 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); note.innerHTML = items.length ? `
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外: ${esc(items.join(';'))}。
这些成交只能按成交价标在「股价」面板上, 股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
` : ''; } } /* ---------------- 视图:Walk-forward ---------------- */ async function viewWalkforwards() { const d = await api('walkforwards'); return `

Walk-forward 样本外验证

每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。
判读要点:单条路径的全期回测会系统性高估策略。 请以样本外均值与稳定性(均值/标准差,<1 表示窗口间差异大于均值本身)为准。
${d.items.length ? `
${d.items.map(wfRec).join('')}
` : `
还没有 Walk-forward 记录。
运行 python -m hdiv backtest --mode walkforward(约 25 分钟)。
`}`; } function wfRec(w) { const o = w.oos || {}; const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown; return `
${esc(w.title)} ${esc(w.status || 'OK')}
${esc((w.created_at || '').slice(0, 16))} 步进 ${w.step_months} 月 数据版本 ${esc((w.data_version || '').slice(0, 10))}
${m != null ? `
样本外均值${pct(m)} 样本外胜率${pct(wr, 1)} 最差回撤${pct(dd)} 有效窗口${o.sample_count ?? '—'}/${o.window_count ?? '—'}
` : ''}
`; } async function viewWalkforwardDetail(wfId) { let d; try { d = await api(`walkforwards/${wfId}`); } catch (e) { return `
样本外
无法加载:${esc(e.message)}
`; } const s = d.summary; const rows = d.windows.map(w => { const i = w.in_sample, o = w.out_of_sample; const excess = (i.total_return != null && o.total_return != null) ? null : null; return ` #${w.window_index} ${esc(w.train_start)} ~ ${esc(w.train_end)} ${esc(w.test_start)} ~ ${esc(w.test_end)} ${num(i.total_return != null ? i.total_return * 100 : null, 2)}% ${num(i.cagr != null ? i.cagr * 100 : null, 2)}% ${num(i.sharpe, 2)} ${num(o.total_return != null ? o.total_return * 100 : null, 2)}% ${num(o.cagr != null ? o.cagr * 100 : null, 2)}% ${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}% ${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp ${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}% ${num(o.sharpe, 2)} ${num(o.trade_count, 0)} P${num(w.frozen_params.entry_yield_percentile, 0)} `; }).join(''); return `
样本外/${esc(d.strategy_id)}

${esc(d.title)}

${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 · ${esc(d.wf_id)}
样本外收益均值
${pct(s.oos_mean)}
中位数 ${pct(s.oos_median)}
样本外胜率
${pct(s.oos_win_rate, 1)}
${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正
基准均值(沪深300)
${pct(s.benchmark_mean)}
同期被动持有
超额收益均值
${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp
超额胜率 ${pct(s.excess_win_rate, 1)}
稳定性
${num(s.oos_stability, 2)}
均值/标准差,<1 表示结论不稳
最差回撤
${pct(s.oos_worst_drawdown)}
样本外最深
窗口数
${s.window_count}
${d.scheme} 滚动

样本内 vs 样本外

若样本内收益明显高于样本外,说明阈值是在训练段「拟合」出来的, 样本外无法复现 —— 这就是过拟合的直接证据。
超额 = 策略 − 基准(沪深300)。本策略的典型形态是 牛市跑输、熊市跑赢:请结合当年的市场环境判读,不要只看均值。
注意区间长度:训练段 5 年、测试段 1 年,因此上图统一用年化口径。 直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。

逐窗口明细

${rows}
训练段(5 年 · 校准参照分布) 测试段(1 年 · 冻结参数)
窗口训练区间测试区间 收益CAGRSharpe 收益年化基准 超额最大回撤 Sharpe成交冻结阈值
冻结阈值是该窗口在训练段校准出、并在测试段强制沿用的买入分位 —— 各窗口若差异很大,说明策略对参数不稳定。

可复现性

策略${esc(d.strategy_id)} v${esc(d.strategy_version)}
配置指纹${esc(d.config_hash || '—')}
数据版本${esc(d.data_version || '—')}
代码版本${esc(d.code_version || '—')}
`; } function mountWalkforwardDetail(d) { const w = d.windows || []; if (!w.length) return; chart('c_wf', { tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}}, legend: {top: 0, textStyle: {color: '#64748B'}}, grid: {left: 64, right: 30, top: 38, bottom: 76}, xAxis: {type: 'category', data: w.map(x => `#${x.window_index}\n${String(x.test_start).slice(0, 7)}`), axisLabel: {color: '#64748B', fontSize: 10, lineHeight: 13}}, yAxis: {type: 'value', name: '年化收益 %', axisLabel: {color: '#64748B', formatter: v => v + '%'}, splitLine: {lineStyle: {color: '#E9EEF6'}}}, series: [ // 用**年化**而非累计:训练段 5 年、测试段 1 年, // 直接比累计收益是不同长度区间的比较,会得出误导性结论。 {name: `样本内年化(${w[0] ? '' : ''}5年)`, type: 'bar', barMaxWidth: 22, itemStyle: {color: COLORS[2]}, data: w.map(x => x.in_sample.cagr != null ? +(x.in_sample.cagr * 100).toFixed(2) : null)}, {name: '样本外年化(1年)', type: 'bar', barMaxWidth: 22, itemStyle: {color: COLORS[0]}, data: w.map(x => x.out_of_sample.cagr != null ? +(x.out_of_sample.cagr * 100).toFixed(2) : null)}, {name: '基准(年内)', type: 'bar', barMaxWidth: 22, itemStyle: {color: COLORS[3]}, data: w.map(x => x.benchmark_return != null ? +(x.benchmark_return * 100).toFixed(2) : null)}, {name: '超额', type: 'line', symbolSize: 7, lineStyle: {width: 1.6, color: COLORS[1]}, itemStyle: {color: COLORS[1]}, data: w.map(x => x.excess_return != null ? +(x.excess_return * 100).toFixed(2) : null)}, {name: '零轴', type: 'line', data: [], markLine: { silent: true, symbol: 'none', lineStyle: {color: '#94A3B8', type: 'dashed', width: 1}, data: [{yAxis: 0}]}}, ], }); } /* ---------------- 视图:归档 ---------------- */ async function viewArchive() { const [us, bs] = await Promise.all([ api('universes?include_archived=1&include_deleted=1'), api('backtests?include_archived=1&include_deleted=1') ]); const aus = us.items.filter(x => x.state !== 'active'); const abs = bs.items.filter(x => x.state !== 'active'); return `

归档与已删除

归档与软删除的记录仍完整保留在数据库中,可随时恢复。

股票池筛选 ${aus.length}

${aus.length ? `
${aus.map(recUniverse).join('')}
` : '
没有归档或删除的筛选记录。
'}

回测 ${abs.length}

${abs.length ? `
${abs.map(recBacktest).join('')}
` : '
没有归档或删除的回测记录。
'}
`; } /* ---------------- 路由 ---------------- */ let currentPath = ''; function parseHash() { const raw = location.hash.replace(/^#/, '') || '/'; const [path, qs] = raw.split('?'); return {parts: path.split('/').filter(Boolean), q: new URLSearchParams(qs || '')}; } async function render() { const view = document.getElementById('view'); const {parts, q} = parseHash(); const path = parts.join('/'); if (path === currentPath) return; currentPath = path; disposeCharts(); document.querySelectorAll('.nav a').forEach(a => { const key = a.dataset.nav; const on = (key === 'home' && !parts.length) || (key === 'universes' && parts[0] === 'universes') || (key === 'backtests' && parts[0] === 'backtests') || (key === 'walkforwards' && parts[0] === 'walkforwards') || (key === 'archive' && parts[0] === 'archive'); a.classList.toggle('active', !!on); }); view.innerHTML = '
加载中…
'; try { let html = '', after = null; if (!parts.length) html = await viewHome(); else if (parts[0] === 'universes' && parts.length === 1) html = await viewUniverses(q); else if (parts[0] === 'universes' && parts.length === 2) { const u = await api(`universes/${parts[1]}`); html = await viewUniverseDetail(parts[1], q); after = () => mountUniverseDetail(u); } else if (parts[0] === 'stocks' && parts.length === 2) { const d = await api(`stocks/${encodeURIComponent(parts[1])}`).catch(() => null); html = await viewStock(parts[1]); if (d) after = () => mountStock(d); } else if (parts[0] === 'backtests' && parts.length === 1) html = await viewBacktests(q); 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]; mountBacktestStock(parts[1], parts[3]); }; } else if (parts[0] === 'backtests' && parts.length === 2) { html = await viewBacktestDetail(parts[1]); after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true)); } else if (parts[0] === 'walkforwards' && parts.length === 1) html = await viewWalkforwards(); else if (parts[0] === 'walkforwards' && parts.length === 2) { const d = await api(`walkforwards/${parts[1]}`); html = await viewWalkforwardDetail(parts[1]); after = () => mountWalkforwardDetail(d); } else if (parts[0] === 'archive') html = await viewArchive(); else html = `
未知页面:${esc(path)}
返回概览
`; view.innerHTML = html; if (after) await after(); } catch (e) { view.innerHTML = `
加载失败:${esc(e.message)}
请确认后端已启动(python -m hdiv web), 且 nginx 已把 /api 反代到该服务。
`; } window.scrollTo(0, 0); } /* ---------------- 交互(事件委托) ---------------- */ document.addEventListener('click', async e => { const btn = e.target.closest('[data-act]'); if (btn) { e.preventDefault(); const id = btn.dataset.id, act = btn.dataset.act, scope = btn.dataset.scope || 'u'; const base = scope === 'b' ? 'backtests' : 'universes'; try { if (act === 'open') { location.hash = `#/${btn.dataset.prefix || base}/${id}`; return; } if (act === 'rename') { const cur = await api(`${base}/${id}`); modal(scope === 'b' ? '命名回测记录' : '命名筛选记录', [{key:'display_name', label:'名称', value: cur.display_name || ''}, {key:'notes', label:'备注', value: cur.notes || '', type:'textarea'}], async v => { await patch(`${base}/${id}`, v); toast('已保存'); 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(); } }); return; } if (act === 'archive') { await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'}); toast(btn.dataset.to === '1' ? '已归档' : '已取消归档'); currentPath = ''; 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; } if (act === 'pf-shift') { shiftPortfolio(parseInt(btn.dataset.d, 10)); return; } if (act === 'st-apply') { 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]); } return; } if (act === 'page') { const {parts, q} = parseHash(); q.set('page', btn.dataset.p); location.hash = `#/${parts.join('/')}?${q.toString()}`; currentPath = ''; render(); return; } } catch (err) { toast(err.message, true); } return; } // 列表页筛选控件 if (e.target.id === 'doSearch' || e.target.id === 'mgo') { const {parts, q} = parseHash(); const qEl = document.getElementById('q') || document.getElementById('mq'); if (qEl) q.set('q', qEl.value.trim()); 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; } }); document.addEventListener('change', e => { if (e.target.id === 'pf-date') { loadPortfolio(e.target.value); return; } if (e.target.id === 'eq-index') { eqState.index = e.target.value; mountEquityChart(eqState.runId, eqState.index) .catch(err => toast(err.message, true)); return; } if (e.target.id === 'incArch' || e.target.id === 'incDel') { const {parts, q} = parseHash(); 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(); } }); document.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.target.id === 'q' || e.target.id === 'mq')) { document.getElementById(e.target.id === 'q' ? 'doSearch' : 'mgo').click(); } }); window.addEventListener('hashchange', () => { currentPath = ''; render(); }); /* ---------------- 启动 ---------------- */ (async function boot() { const st = document.getElementById('api-status'); try { await api('health'); st.textContent = 'API 正常'; st.className = 'badge ok'; try { const dc = await api('config/display'); Object.assign(FMT, dc); document.documentElement.style.setProperty('--max-width', (dc.max_width || 1440) + 'px'); } catch (e) { /* 取不到就用默认精度,不影响使用 */ } } catch (e) { st.textContent = 'API 不可用'; st.className = 'badge fail'; } document.getElementById('foot-note').textContent = '数据来自数据库,页面不做任何指标计算 · 删除为软删除,数据不做物理清除'; render(); })();