功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。
包含:
- Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面)
- 后端接口:web/server.py 路由、web/analysis.py(新增个股分析)
- 报告层:report/format.py(新增统一数字格式化 NumFmt)、
report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、
report/renderer.py
- 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes)
- 筛选:universe/selector.py、universe/filters/dividend.py
- 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py
- 部署:deploy/install-service.sh
- 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、
tests/test_web.py、tests/test_universe.py
提交时全量测试 403 项通过。
This commit is contained in:
+762
-24
@@ -18,10 +18,19 @@ const COLORS = ['#1E40AF','#D97706','#3B82F6','#059669','#DC2626',
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g,
|
||||
c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
|
||||
const num = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v))
|
||||
? '—' : Number(v).toLocaleString('zh-CN', {minimumFractionDigits: d, maximumFractionDigits: d});
|
||||
const pct = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v))
|
||||
? '—' : (v * 100).toFixed(d) + '%';
|
||||
// 显示精度来自 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' : ''));
|
||||
@@ -59,6 +68,16 @@ function chart(id, option) {
|
||||
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()));
|
||||
|
||||
/* ---------------- 模态框 ---------------- */
|
||||
@@ -634,7 +653,33 @@ async function viewBacktestDetail(runId) {
|
||||
${rc.balanced ? '成本与分红入账完整无遗漏。' : '<b>此时不应采信上方绩效指标。</b>'}
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>净值曲线与基准</h2><div id="c_eq" class="chart"></div></div>
|
||||
<div class="card">
|
||||
<h2>净值曲线与基准</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">叠加指数(右轴)</span>
|
||||
<select id="eq-index" style="width:auto"></select>
|
||||
<span class="small muted" id="eq-index-note"></span>
|
||||
</div>
|
||||
<div id="c_eq" class="chart"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
<b>右轴</b>叠加的是所选指数的<b>点位</b>(不归一化),左轴是净值(起点 = 1);
|
||||
虚线「基准净值」是本次回测<b>自带</b>的基准,已归一到 1,可直接与策略净值比高低。
|
||||
指数默认沪深300,可在上方下拉切换或取消叠加。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="portfolio-card">
|
||||
<h2>持仓明细 <span class="badge info" id="pf-badge">…</span></h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">日期</span>
|
||||
<input type="date" id="pf-date" style="width:auto">
|
||||
<button data-act="pf-shift" data-d="-1">← 上一交易日</button>
|
||||
<button data-act="pf-shift" data-d="1">下一交易日 →</button>
|
||||
<button data-act="pf-shift" data-d="0">最新</button>
|
||||
<span class="small muted" id="pf-range"></span>
|
||||
</div>
|
||||
<div id="pf-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>回测条件</h2>
|
||||
@@ -678,26 +723,105 @@ async function viewBacktestDetail(runId) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function mountBacktestDetail(runId) {
|
||||
const eq = await api(`backtests/${runId}/equity`);
|
||||
if (eq.dates && eq.dates.length) {
|
||||
chart('c_eq', {
|
||||
tooltip:{trigger:'axis'}, legend:{top:0,textStyle:{color:'#64748B'}},
|
||||
grid:{left:66,right: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'}}},
|
||||
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}],
|
||||
series:[
|
||||
{name:'策略净值',type:'line',data:eq.nav,showSymbol:false,lineStyle:{width:2,color:COLORS[0]}},
|
||||
{name:'基准净值',type:'line',data:eq.bench,showSymbol:false,
|
||||
lineStyle:{width:1.4,color:COLORS[1],type:'dashed'}},
|
||||
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,yAxisIndex:0,
|
||||
lineStyle:{width:1,color:COLORS[4]},areaStyle:{opacity:.1}}]
|
||||
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 = '<div class="empty">该回测没有净值曲线</div>';
|
||||
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 `<div style="display:flex;gap:18px;justify-content:space-between;line-height:1.7">
|
||||
<span>${p.marker}${esc(p.seriesName)}</span>
|
||||
<b style="font-family:monospace">${text}</b></div>`;
|
||||
};
|
||||
|
||||
chart('c_eq', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'},
|
||||
formatter: ps => `<div style="font-weight:600;margin-bottom:4px">`
|
||||
+ `${esc(ps.length ? ps[0].axisValue : '')}</div>${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 = '<span class="badge warn">该指数在本次回测区间内没有行情</span>';
|
||||
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 =>
|
||||
`<option value="${esc(x.code)}"${x.code === eqState.index ? ' selected' : ''}>`
|
||||
+ `${esc(x.name)}(${esc(x.code)})</option>`).join('')
|
||||
+ `<option value=""${eqState.index ? '' : ' selected'}>不叠加</option>`;
|
||||
}
|
||||
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');
|
||||
@@ -709,7 +833,7 @@ async function mountBacktestDetail(runId) {
|
||||
<th>已实现盈亏</th><th>持仓天数</th><th class="l">触发理由</th></tr></thead>
|
||||
<tbody>${t.items.map((x,i)=>`<tr>
|
||||
<td class="num">${i+1}</td>
|
||||
<td class="l"><a href="#/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="num">${esc(x.signal_date)}</td><td class="num">${esc(x.execution_date)}</td>
|
||||
<td><span class="badge ${x.side==='BUY'?'gain':'loss'}">${esc(x.side)}</span></td>
|
||||
<td class="num">${num(x.price,3)}</td><td class="num">${num(x.quantity,0)}</td>
|
||||
@@ -738,6 +862,577 @@ async function mountBacktestDetail(runId) {
|
||||
} 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 = '<div class="loading">加载中…</div>';
|
||||
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) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td class="l"><a href="#/backtests/${esc(d.run_id)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l">${esc(x.name || '')}</td>
|
||||
<td class="l small muted">${esc(x.industry || '')}</td>
|
||||
<td class="num">${num(x.quantity, 0)}</td>
|
||||
<td class="num">${num(x.avg_cost, 3)}</td>
|
||||
<td class="num">${num(x.close, 3)}</td>
|
||||
<td class="num">${num(x.market_value, 0)}</td>
|
||||
<td class="num">${pct(x.weight)}</td>
|
||||
<td class="num ${sign(x.unrealized_pnl)}">${num(x.unrealized_pnl, 0)}</td>
|
||||
<td class="num ${sign(x.pnl_pct)}">${pct(x.pnl_pct)}</td>
|
||||
<td class="num">${x.holding_days ?? '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="kpi-grid" style="margin-bottom:14px">
|
||||
<div class="kpi"><div class="label">总资产</div><div class="value">${yi(e.total_value)}</div>
|
||||
<div class="note">净值 ${num(e.nav, 4)}</div></div>
|
||||
<div class="kpi"><div class="label">现金</div><div class="value">${yi(e.cash)}</div>
|
||||
<div class="note">占比 ${pct(e.total_value ? e.cash / e.total_value : null)}</div></div>
|
||||
<div class="kpi"><div class="label">持仓市值</div><div class="value">${yi(e.position_value)}</div>
|
||||
<div class="note">${sm.count} 只</div></div>
|
||||
<div class="kpi"><div class="label">浮动盈亏</div>
|
||||
<div class="value ${sign(sm.unrealized_pnl)}">${yi(sm.unrealized_pnl)}</div>
|
||||
<div class="note">成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}</div></div>
|
||||
<div class="kpi"><div class="label">当日涨跌</div>
|
||||
<div class="value ${sign(e.daily_return)}">${pct(e.daily_return)}</div>
|
||||
<div class="note">累计 ${pct(e.cum_return)}</div></div>
|
||||
<div class="kpi"><div class="label">回撤</div>
|
||||
<div class="value ${e.drawdown < 0 ? 'loss' : ''}">${pct(e.drawdown)}</div>
|
||||
<div class="note">相对历史高点</div></div>
|
||||
</div>
|
||||
${d.positions.length ? `<div class="tw"><table class="data">
|
||||
<thead><tr><th>#</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>持仓天数</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr><td colspan="7" class="l"><b>合计</b></td>
|
||||
<td class="num"><b>${num(sm.market_value, 0)}</b></td>
|
||||
<td class="num"><b>${pct(e.total_value ? sm.market_value / e.total_value : null)}</b></td>
|
||||
<td class="num ${sign(sm.unrealized_pnl)}"><b>${num(sm.unrealized_pnl, 0)}</b></td>
|
||||
<td class="num ${sign(sm.unrealized_pnl_pct)}"><b>${pct(sm.unrealized_pnl_pct)}</b></td>
|
||||
<td></td></tr></tfoot>
|
||||
</table></div>` : '<div class="empty">该日空仓(100% 现金)</div>'}
|
||||
<div class="callout" style="margin-top:12px">
|
||||
点击任意股票代码可打开<b>该股在本次回测中的买卖点与趋势图</b>。
|
||||
</div>`;
|
||||
} catch (err) {
|
||||
host.innerHTML = `<div class="callout fail">加载失败:${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 `<div class="crumb"><a href="#/backtests/${esc(runId)}">返回回测</a></div>
|
||||
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
|
||||
}
|
||||
const st = d.stats;
|
||||
const i = d.info;
|
||||
return `
|
||||
<div class="crumb"><a href="#/backtests">回测</a><span>/</span>
|
||||
<a href="#/backtests/${esc(runId)}">${esc(runId.slice(0, 12))}…</a><span>/</span>个股</div>
|
||||
<h1 class="page">${esc(i.name || symbol)}
|
||||
<span class="mono muted" style="font-size:15px">${esc(symbol)}</span></h1>
|
||||
<div class="page-sub">${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)}
|
||||
${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="label">成交笔数</div><div class="value">${st.trade_count}</div>
|
||||
<div class="note">买 ${st.buy_count} / 卖 ${st.sell_count}</div></div>
|
||||
<div class="kpi"><div class="label">买入金额</div><div class="value">${yi(st.buy_amount)}</div>
|
||||
<div class="note">卖出 ${yi(st.sell_amount)}</div></div>
|
||||
<div class="kpi"><div class="label">已实现盈亏</div>
|
||||
<div class="value ${sign(st.realized_pnl)}">${yi(st.realized_pnl)}</div>
|
||||
<div class="note">仅平仓部分</div></div>
|
||||
<div class="kpi"><div class="label">交易费用</div><div class="value">${num(st.total_fees, 2)}</div>
|
||||
<div class="note">佣金+印花税+过户费</div></div>
|
||||
<div class="kpi"><div class="label">首笔 / 末笔</div>
|
||||
<div class="value" style="font-size:14px">${esc(st.first_trade || '—')}<br>${esc(st.last_trade || '—')}</div>
|
||||
<div class="note">成交日期</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>趋势与买卖点</h2>
|
||||
<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>上,
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
|
||||
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
|
||||
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
|
||||
${st.trade_count ? `<div class="tw"><table class="data">
|
||||
<thead><tr><th>#</th><th>信号日</th><th>成交日</th><th>方向</th>
|
||||
<th>价格</th><th>股数</th><th>金额</th><th>佣金</th><th>印花税</th><th>过户费</th>
|
||||
<th>费用合计</th><th>滑点成本</th><th>已实现盈亏</th><th>持仓天数</th>
|
||||
<th class="l">触发理由</th></tr></thead>
|
||||
<tbody>${d.trades.map((t, i2) => `<tr>
|
||||
<td class="num">${i2 + 1}</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">${num(t.price, 3)}</td><td class="num">${num(t.quantity, 0)}</td>
|
||||
<td class="num">${num(t.amount, 0)}</td>
|
||||
<td class="num">${num(t.commission, 2)}</td><td class="num">${num(t.stamp_tax, 2)}</td>
|
||||
<td class="num">${num(t.transfer_fee, 2)}</td><td class="num">${num(t.total_cost, 2)}</td>
|
||||
<td class="num">${num(t.slippage_cost, 2)}</td>
|
||||
<td class="num ${sign(t.realized_pnl)}">${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)}</td>
|
||||
<td class="num">${t.holding_days ?? '—'}</td>
|
||||
<td class="l small">${esc(t.reason_text)}</td></tr>`).join('')}
|
||||
</tbody></table></div>` : '<div class="empty">该股在本次回测中没有成交</div>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="empty">请至少勾选一个指标</div>';
|
||||
const n0 = document.getElementById('c_stock_note');
|
||||
if (n0) n0.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
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 = `<div class="callout fail">图表加载失败:${esc(e.message)}</div>`;
|
||||
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 = `<div style="font-weight:600;margin-bottom:4px">${esc(date)}</div>`;
|
||||
const rows = idx < 0
|
||||
? `<div style="color:#94A3B8;font-size:12px">该日超出指标数据区间,仅此处的成交记录</div>`
|
||||
: shown.map((k, i) => `
|
||||
<div style="display:flex;gap:16px;justify-content:space-between;line-height:1.7">
|
||||
<span style="color:${COLORS[i % COLORS.length]}">● ${esc(SERIES_LABEL[k] || k)}</span>
|
||||
<b style="font-family:monospace">${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))}</b>
|
||||
</div>`).join('');
|
||||
const trs = (tradesOn.get(date) || []).map(t => `
|
||||
<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #CBD5E1">
|
||||
<b style="color:${t.side === 'BUY' ? COLORS[4] : COLORS[3]}">
|
||||
${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'}</b>
|
||||
<span style="font-family:monospace">${num(t.price, 3)} 元</span> ·
|
||||
${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元<br/>
|
||||
<span style="color:#64748B;font-size:12px">${esc(t.reason_text)}</span>
|
||||
</div>`).join('');
|
||||
return `<div style="max-width:340px;white-space:normal">${head}${rows}${trs}</div>`;
|
||||
},
|
||||
},
|
||||
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 ? `<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 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
|
||||
</div>` : '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 视图:Walk-forward ---------------- */
|
||||
|
||||
async function viewWalkforwards() {
|
||||
const d = await api('walkforwards');
|
||||
return `
|
||||
<h1 class="page">Walk-forward 样本外验证</h1>
|
||||
<div class="page-sub">每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。</div>
|
||||
<div class="callout warn">
|
||||
<b>判读要点</b>:单条路径的全期回测会系统性高估策略。
|
||||
请以<b>样本外均值</b>与<b>稳定性</b>(均值/标准差,<1 表示窗口间差异大于均值本身)为准。
|
||||
</div>
|
||||
${d.items.length ? `<div class="rec-list">${d.items.map(wfRec).join('')}</div>`
|
||||
: `<div class="empty">还没有 Walk-forward 记录。<br>
|
||||
运行 <code class="mono">python -m hdiv backtest --mode walkforward</code>(约 25 分钟)。</div>`}`;
|
||||
}
|
||||
|
||||
function wfRec(w) {
|
||||
const o = w.oos || {};
|
||||
const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown;
|
||||
return `<div class="rec">
|
||||
<div class="rec-main">
|
||||
<div class="rec-title">
|
||||
<a href="#/walkforwards/${esc(w.wf_id)}">${esc(w.title)}</a>
|
||||
<span class="badge info">${esc(w.status || 'OK')}</span>
|
||||
</div>
|
||||
<div class="rec-meta">
|
||||
<span class="mono">${esc((w.created_at || '').slice(0, 16))}</span>
|
||||
<span>步进 ${w.step_months} 月</span>
|
||||
<span>数据版本 <span class="mono">${esc((w.data_version || '').slice(0, 10))}</span></span>
|
||||
</div>
|
||||
${m != null ? `<div class="rec-metrics">
|
||||
<span class="rec-metric">样本外均值<b class="${sign(m)}">${pct(m)}</b></span>
|
||||
<span class="rec-metric">样本外胜率<b>${pct(wr, 1)}</b></span>
|
||||
<span class="rec-metric">最差回撤<b class="loss">${pct(dd)}</b></span>
|
||||
<span class="rec-metric">有效窗口<b>${o.sample_count ?? '—'}/${o.window_count ?? '—'}</b></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="rec-actions">
|
||||
<button class="primary" data-act="open" data-id="${esc(w.wf_id)}" data-prefix="walkforwards">
|
||||
打开逐窗口结果</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function viewWalkforwardDetail(wfId) {
|
||||
let d;
|
||||
try { d = await api(`walkforwards/${wfId}`); }
|
||||
catch (e) {
|
||||
return `<div class="crumb"><a href="#/walkforwards">样本外</a></div>
|
||||
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
|
||||
}
|
||||
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 `<tr>
|
||||
<td class="num">#${w.window_index}</td>
|
||||
<td class="num">${esc(w.train_start)} ~ ${esc(w.train_end)}</td>
|
||||
<td class="num">${esc(w.test_start)} ~ ${esc(w.test_end)}</td>
|
||||
<td class="num">${num(i.total_return != null ? i.total_return * 100 : null, 2)}%</td>
|
||||
<td class="num"><b>${num(i.cagr != null ? i.cagr * 100 : null, 2)}%</b></td>
|
||||
<td class="num">${num(i.sharpe, 2)}</td>
|
||||
<td class="num ${sign(o.total_return)}"><b>${num(o.total_return != null ? o.total_return * 100 : null, 2)}%</b></td>
|
||||
<td class="num ${sign(o.cagr)}">${num(o.cagr != null ? o.cagr * 100 : null, 2)}%</td>
|
||||
<td class="num">${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}%</td>
|
||||
<td class="num ${sign(w.excess_return)}"><b>${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp</b></td>
|
||||
<td class="num loss">${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}%</td>
|
||||
<td class="num">${num(o.sharpe, 2)}</td>
|
||||
<td class="num">${num(o.trade_count, 0)}</td>
|
||||
<td class="num">P${num(w.frozen_params.entry_yield_percentile, 0)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="crumb"><a href="#/walkforwards">样本外</a><span>/</span>${esc(d.strategy_id)}</div>
|
||||
<h1 class="page">${esc(d.title)}</h1>
|
||||
<div class="page-sub">
|
||||
${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 ·
|
||||
<span class="mono">${esc(d.wf_id)}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="label">样本外收益均值</div>
|
||||
<div class="value ${sign(s.oos_mean)}">${pct(s.oos_mean)}</div>
|
||||
<div class="note">中位数 ${pct(s.oos_median)}</div></div>
|
||||
<div class="kpi"><div class="label">样本外胜率</div>
|
||||
<div class="value">${pct(s.oos_win_rate, 1)}</div>
|
||||
<div class="note">${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正</div></div>
|
||||
<div class="kpi"><div class="label">基准均值(沪深300)</div>
|
||||
<div class="value">${pct(s.benchmark_mean)}</div>
|
||||
<div class="note">同期被动持有</div></div>
|
||||
<div class="kpi"><div class="label">超额收益均值</div>
|
||||
<div class="value ${sign(s.excess_mean)}">${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp</div>
|
||||
<div class="note">超额胜率 ${pct(s.excess_win_rate, 1)}</div></div>
|
||||
<div class="kpi"><div class="label">稳定性</div>
|
||||
<div class="value ${s.oos_stability != null && s.oos_stability < 0 ? 'loss' : ''}">${num(s.oos_stability, 2)}</div>
|
||||
<div class="note">均值/标准差,<1 表示结论不稳</div></div>
|
||||
<div class="kpi"><div class="label">最差回撤</div>
|
||||
<div class="value loss">${pct(s.oos_worst_drawdown)}</div>
|
||||
<div class="note">样本外最深</div></div>
|
||||
<div class="kpi"><div class="label">窗口数</div>
|
||||
<div class="value">${s.window_count}</div>
|
||||
<div class="note">${d.scheme} 滚动</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>样本内 vs 样本外</h2>
|
||||
<div id="c_wf" class="chart"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
若<b>样本内收益明显高于样本外</b>,说明阈值是在训练段「拟合」出来的,
|
||||
样本外无法复现 —— 这就是过拟合的直接证据。<br>
|
||||
<b>超额 = 策略 − 基准(沪深300)</b>。本策略的典型形态是
|
||||
<b>牛市跑输、熊市跑赢</b>:请结合当年的市场环境判读,不要只看均值。<br>
|
||||
<b>注意区间长度</b>:训练段 5 年、测试段 1 年,因此上图统一用<b>年化</b>口径。
|
||||
直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐窗口明细</h2>
|
||||
<div class="tw"><table class="data">
|
||||
<thead>
|
||||
<tr><th colspan="6" class="l" style="text-align:center">训练段(5 年 · 校准参照分布)</th>
|
||||
<th colspan="7" class="l" style="text-align:center;border-left:2px solid #DBEAFE">测试段(1 年 · 冻结参数)</th></tr>
|
||||
<tr><th>窗口</th><th>训练区间</th><th>测试区间</th>
|
||||
<th>收益</th><th>CAGR</th><th>Sharpe</th>
|
||||
<th style="border-left:2px solid #DBEAFE">收益</th><th>年化</th><th>基准</th>
|
||||
<th>超额</th><th>最大回撤</th>
|
||||
<th>Sharpe</th><th>成交</th><th>冻结阈值</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
<b>冻结阈值</b>是该窗口在训练段校准出、并在测试段强制沿用的买入分位 ——
|
||||
各窗口若差异很大,说明策略对参数不稳定。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>可复现性</h2>
|
||||
<div class="tw"><table class="data"><tbody>
|
||||
<tr><td class="l">策略</td><td class="l mono">${esc(d.strategy_id)} v${esc(d.strategy_version)}</td></tr>
|
||||
<tr><td class="l">配置指纹</td><td class="l mono">${esc(d.config_hash || '—')}</td></tr>
|
||||
<tr><td class="l">数据版本</td><td class="l mono">${esc(d.data_version || '—')}</td></tr>
|
||||
<tr><td class="l">代码版本</td><td class="l mono">${esc(d.code_version || '—')}</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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([
|
||||
@@ -778,6 +1473,7 @@ async function render() {
|
||||
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);
|
||||
});
|
||||
@@ -798,10 +1494,22 @@ async function render() {
|
||||
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 = `<div class="callout warn">未知页面:<span class="mono">${esc(path)}</span>
|
||||
<br><a href="#/">返回概览</a></div>`;
|
||||
@@ -824,7 +1532,7 @@ document.addEventListener('click', async e => {
|
||||
const base = scope === 'b' ? 'backtests' : 'universes';
|
||||
try {
|
||||
if (act === 'open') {
|
||||
location.hash = `#/${base}/${id}`; return;
|
||||
location.hash = `#/${btn.dataset.prefix || base}/${id}`; return;
|
||||
}
|
||||
if (act === 'rename') {
|
||||
const cur = await api(`${base}/${id}`);
|
||||
@@ -854,6 +1562,21 @@ document.addEventListener('click', async e => {
|
||||
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);
|
||||
@@ -877,6 +1600,16 @@ document.addEventListener('click', async e => {
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -900,6 +1633,11 @@ window.addEventListener('hashchange', () => { currentPath = ''; render(); });
|
||||
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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user