- Add html_report.py module for Playwright-based screenshot generation - Add generate_html_report() method to SimpleRotationStrategy - Modify backtest_viewer.html to use window-scoped variables for external injection - Inject monthly/yearly returns table into screenshot - Auto-generate HTML report in __main__ after export_results() Output: simple_rotation_html_report.png with ranking table + monthly returns
726 lines
26 KiB
HTML
726 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ETF轮动策略回测回放器</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, 'SF Mono', 'Menlo', monospace; background: #0d1117; color: #c9d1d9; font-size: 13px; }
|
||
|
||
.loader { display: flex; align-items: center; justify-content: center; height: 100vh; flex-direction: column; gap: 16px; }
|
||
.loader input[type="file"] { display: none; }
|
||
.loader label { padding: 16px 32px; background: #238636; color: #fff; border-radius: 8px; cursor: pointer; font-size: 16px; }
|
||
.loader label:hover { background: #2ea043; }
|
||
.loader .hint { color: #8b949e; }
|
||
|
||
.app { display: none; height: 100vh; flex-direction: column; overflow: hidden; }
|
||
.app.active { display: flex; }
|
||
|
||
/* Header */
|
||
.header { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
|
||
.header .date-display { font-size: 18px; font-weight: 600; color: #58a6ff; min-width: 120px; }
|
||
.header .nav-val { font-size: 16px; font-weight: 600; min-width: 100px; }
|
||
.header .daily-ret { font-size: 14px; min-width: 80px; font-weight: 600; }
|
||
.header .rebal-badge { background: #da3633; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; }
|
||
|
||
/* Controls */
|
||
.controls { display: flex; align-items: center; gap: 8px; padding: 6px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; }
|
||
.controls button { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||
.controls button:hover { background: #30363d; }
|
||
.controls button:active { background: #484f58; }
|
||
.controls input[type="date"] { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
|
||
.controls input[type="range"] { width: 80px; }
|
||
.controls .speed-label { color: #8b949e; font-size: 11px; }
|
||
.controls .day-counter { color: #8b949e; font-size: 11px; margin-left: auto; }
|
||
|
||
/* Nav Chart */
|
||
.chart-container { height: 180px; padding: 8px 16px; background: #0d1117; flex-shrink: 0; border-bottom: 1px solid #30363d; position: relative; }
|
||
.chart-container canvas { width: 100%; height: 100%; }
|
||
|
||
/* Stats panel */
|
||
.stats-panel { display: flex; gap: 16px; padding: 8px 16px; background: #161b22; border-bottom: 1px solid #30363d; flex-shrink: 0; flex-wrap: wrap; }
|
||
.stat-item { display: flex; flex-direction: column; min-width: 90px; }
|
||
.stat-item .stat-label { color: #8b949e; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.stat-item .stat-value { font-size: 14px; font-weight: 600; margin-top: 1px; }
|
||
|
||
/* Main content */
|
||
.main { display: grid; grid-template-columns: 280px 1fr; flex: 1; overflow: hidden; }
|
||
|
||
/* Holdings panel */
|
||
.holdings-panel { padding: 12px; border-right: 1px solid #30363d; overflow-y: auto; background: #161b22; }
|
||
.holdings-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
|
||
.holding-card { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px; margin-bottom: 8px; }
|
||
.holding-card.positive { border-left: 3px solid #3fb950; }
|
||
.holding-card.negative { border-left: 3px solid #da3633; }
|
||
.holding-card .code { font-weight: 600; color: #58a6ff; font-size: 14px; }
|
||
.holding-card .name { color: #8b949e; font-size: 11px; }
|
||
.holding-card .details { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; margin-top: 6px; font-size: 11px; }
|
||
.holding-card .details .label { color: #8b949e; }
|
||
.holding-card .details .value { text-align: right; }
|
||
|
||
/* Ranking table */
|
||
.ranking-panel { display: flex; flex-direction: column; overflow: hidden; }
|
||
.ranking-table-wrap { flex: 1; overflow-y: auto; padding: 0 12px 12px; }
|
||
.ranking-panel h3 { color: #8b949e; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; padding: 12px 12px 8px; flex-shrink: 0; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||
thead { position: sticky; top: 0; background: #0d1117; z-index: 1; }
|
||
th { text-align: left; padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 500; }
|
||
td { padding: 5px 8px; border-bottom: 1px solid #21262d; }
|
||
tr.held { background: rgba(56, 139, 253, 0.1); }
|
||
tr.held td:first-child { border-left: 3px solid #58a6ff; }
|
||
tr.below-threshold { color: #484f58; }
|
||
.positive { color: #3fb950; }
|
||
.negative { color: #da3633; }
|
||
.neutral { color: #8b949e; }
|
||
|
||
/* Rebalance bar */
|
||
.rebalance-bar { padding: 8px 16px; background: #1c1206; border-top: 1px solid #5a3e00; flex-shrink: 0; display: none; font-size: 12px; }
|
||
.rebalance-bar.active { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||
.rebalance-bar .tag-in { background: #238636; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
|
||
.rebalance-bar .tag-out { background: #da3633; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="loader" id="loader">
|
||
<div style="font-size: 20px; font-weight: 600;">ETF 轮动策略回测回放器</div>
|
||
<div class="hint">加载 backtest_detail.json 开始回放</div>
|
||
<label for="file-input">选择 JSON 文件</label>
|
||
<input type="file" id="file-input" accept=".json">
|
||
<div class="hint" id="load-status"></div>
|
||
</div>
|
||
|
||
<div class="app" id="app">
|
||
<div class="header">
|
||
<span class="date-display" id="date-display"></span>
|
||
<span class="nav-val" id="nav-display"></span>
|
||
<span class="daily-ret" id="ret-display"></span>
|
||
<span class="rebal-badge" id="rebal-badge" style="display:none">调仓</span>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<button id="btn-prev" title="上一天 (←)">◀ 前一天</button>
|
||
<button id="btn-next" title="下一天 (→)">后一天 ▶</button>
|
||
<button id="btn-prev-rebal" title="上一调仓日">◀◀ 上一调仓</button>
|
||
<button id="btn-next-rebal" title="下一调仓日">下一调仓 ▶▶</button>
|
||
<input type="date" id="date-picker">
|
||
<button id="btn-play" title="播放/暂停 (Space)">▶ 播放</button>
|
||
<span class="speed-label">速度:</span>
|
||
<input type="range" id="speed-slider" min="1" max="10" value="5">
|
||
<span class="day-counter" id="day-counter"></span>
|
||
</div>
|
||
|
||
<div class="chart-container">
|
||
<canvas id="nav-chart"></canvas>
|
||
</div>
|
||
|
||
<div class="stats-panel" id="stats-panel"></div>
|
||
|
||
<div class="main">
|
||
<div class="holdings-panel">
|
||
<h3>当日持仓</h3>
|
||
<div id="holdings-cards"></div>
|
||
</div>
|
||
<div class="ranking-panel">
|
||
<h3>全部标的排名 (按动量排序)</h3>
|
||
<div class="ranking-table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th>代码</th>
|
||
<th>名称</th>
|
||
<th>大类</th>
|
||
<th>动量</th>
|
||
<th>阈值</th>
|
||
<th>指数价</th>
|
||
<th>ETF价</th>
|
||
<th>指数收益</th>
|
||
<th>ETF收益</th>
|
||
<th>溢价率</th>
|
||
<th>持仓</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="ranking-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rebalance-bar" id="rebalance-bar"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// Use window. to allow external access (for Playwright screenshot injection)
|
||
window.DATA = null;
|
||
window.currentIdx = 0;
|
||
window.playing = false;
|
||
window.playTimer = null;
|
||
window.rebalanceDays = [];
|
||
|
||
// Convenience aliases for script-internal use
|
||
const DATA_PROXY = { get data() { return window.DATA; }, set data(v) { window.DATA = v; } };
|
||
|
||
const $ = id => document.getElementById(id);
|
||
|
||
// File loading
|
||
$('file-input').addEventListener('change', e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
$('load-status').textContent = '加载中...';
|
||
const reader = new FileReader();
|
||
reader.onload = ev => {
|
||
try {
|
||
DATA = JSON.parse(ev.target.result);
|
||
initApp();
|
||
} catch (err) {
|
||
$('load-status').textContent = '解析失败: ' + err.message;
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
});
|
||
|
||
function initApp() {
|
||
$('loader').style.display = 'none';
|
||
$('app').classList.add('active');
|
||
|
||
// Build rebalance day index
|
||
rebalanceDays = [];
|
||
DATA.days.forEach((d, i) => { if (d.is_rebalance) rebalanceDays.push(i); });
|
||
|
||
// Set date picker range
|
||
$('date-picker').min = DATA.days[0].date;
|
||
$('date-picker').max = DATA.days[DATA.days.length - 1].date;
|
||
|
||
drawChart();
|
||
render(0);
|
||
}
|
||
|
||
// Navigation
|
||
$('btn-prev').addEventListener('click', () => navigate(-1));
|
||
$('btn-next').addEventListener('click', () => navigate(1));
|
||
$('btn-prev-rebal').addEventListener('click', () => navigateRebalance(-1));
|
||
$('btn-next-rebal').addEventListener('click', () => navigateRebalance(1));
|
||
$('date-picker').addEventListener('change', e => {
|
||
const target = e.target.value;
|
||
const idx = DATA.days.findIndex(d => d.date === target);
|
||
if (idx >= 0) { currentIdx = idx; render(currentIdx); drawChartMarker(); }
|
||
});
|
||
|
||
$('btn-play').addEventListener('click', togglePlay);
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.tagName === 'INPUT') return;
|
||
if (e.key === 'ArrowLeft') { e.preventDefault(); navigate(-1); }
|
||
if (e.key === 'ArrowRight') { e.preventDefault(); navigate(1); }
|
||
if (e.key === ' ') { e.preventDefault(); togglePlay(); }
|
||
});
|
||
|
||
function navigate(delta) {
|
||
const next = currentIdx + delta;
|
||
if (next >= 0 && next < DATA.days.length) {
|
||
currentIdx = next;
|
||
render(currentIdx);
|
||
drawChartMarker();
|
||
}
|
||
}
|
||
|
||
function navigateRebalance(dir) {
|
||
if (dir > 0) {
|
||
const next = rebalanceDays.find(i => i > currentIdx);
|
||
if (next !== undefined) { currentIdx = next; render(currentIdx); drawChartMarker(); }
|
||
} else {
|
||
const prev = [...rebalanceDays].reverse().find(i => i < currentIdx);
|
||
if (prev !== undefined) { currentIdx = prev; render(currentIdx); drawChartMarker(); }
|
||
}
|
||
}
|
||
|
||
function togglePlay() {
|
||
playing = !playing;
|
||
$('btn-play').innerHTML = playing ? '▮▮ 暂停' : '▶ 播放';
|
||
if (playing) {
|
||
playTick();
|
||
} else {
|
||
clearTimeout(playTimer);
|
||
}
|
||
}
|
||
|
||
function playTick() {
|
||
if (!playing) return;
|
||
if (currentIdx < DATA.days.length - 1) {
|
||
currentIdx++;
|
||
render(currentIdx);
|
||
drawChartMarker();
|
||
const speed = parseInt($('speed-slider').value);
|
||
const delay = 1100 - speed * 100; // 1000ms ~ 100ms
|
||
playTimer = setTimeout(playTick, delay);
|
||
} else {
|
||
playing = false;
|
||
$('btn-play').innerHTML = '▶ 播放';
|
||
}
|
||
}
|
||
|
||
function fmtPct(v, decimals) {
|
||
if (v === null || v === undefined) return '-';
|
||
return (v * 100).toFixed(decimals !== undefined ? decimals : 2) + '%';
|
||
}
|
||
|
||
function fmtNum(v, decimals) {
|
||
if (v === null || v === undefined) return '-';
|
||
return Number(v).toFixed(decimals !== undefined ? decimals : 2);
|
||
}
|
||
|
||
function retClass(v) {
|
||
if (v === null || v === undefined) return 'neutral';
|
||
return v > 0 ? 'positive' : v < 0 ? 'negative' : 'neutral';
|
||
}
|
||
|
||
function render(idx) {
|
||
const day = DATA.days[idx];
|
||
const meta = DATA.meta;
|
||
|
||
// Header
|
||
$('date-display').textContent = day.date;
|
||
$('nav-display').textContent = '净值 ' + fmtNum(day.nav, 4);
|
||
$('nav-display').className = 'nav-val';
|
||
|
||
const retEl = $('ret-display');
|
||
retEl.textContent = (day.daily_return >= 0 ? '+' : '') + fmtPct(day.daily_return);
|
||
retEl.className = 'daily-ret ' + retClass(day.daily_return);
|
||
|
||
const badge = $('rebal-badge');
|
||
badge.style.display = day.is_rebalance ? 'inline' : 'none';
|
||
|
||
$('day-counter').textContent = `第 ${idx + 1} / ${DATA.days.length} 天 | 调仓 ${rebalanceDays.filter(i => i <= idx).length} 次`;
|
||
$('date-picker').value = day.date;
|
||
|
||
// Stats panel
|
||
renderStats(idx);
|
||
|
||
// Holdings cards
|
||
renderHoldings(day, meta);
|
||
|
||
// Ranking table
|
||
renderRanking(day, meta);
|
||
|
||
// Rebalance bar
|
||
renderRebalanceBar(day, meta);
|
||
}
|
||
|
||
function renderStats(idx) {
|
||
const panel = $('stats-panel');
|
||
const days = DATA.days;
|
||
|
||
// Slice up to current day
|
||
const navs = [];
|
||
for (let i = 0; i <= idx; i++) {
|
||
if (days[i].nav !== null) navs.push(days[i].nav);
|
||
}
|
||
if (navs.length < 2) {
|
||
panel.innerHTML = '<span class="stat-item"><span class="stat-label">等待数据...</span></span>';
|
||
return;
|
||
}
|
||
|
||
const startNav = navs[0];
|
||
const curNav = navs[navs.length - 1];
|
||
const totalReturn = curNav / startNav - 1;
|
||
const tradingDays = navs.length;
|
||
const years = tradingDays / 252; // 统一使用 252 天(A股标准),与 rotation.py 一致
|
||
const cagr = years > 0 ? Math.pow(curNav / startNav, 1 / years) - 1 : 0;
|
||
|
||
// Max drawdown
|
||
let maxDD = 0;
|
||
let peak = navs[0];
|
||
for (let i = 1; i < navs.length; i++) {
|
||
if (navs[i] > peak) peak = navs[i];
|
||
const dd = navs[i] / peak - 1;
|
||
if (dd < maxDD) maxDD = dd;
|
||
}
|
||
|
||
// Volatility & Sharpe(使用 252 天,与 rotation.py 一致)
|
||
const rets = [];
|
||
for (let i = 1; i < navs.length; i++) {
|
||
rets.push(navs[i] / navs[i-1] - 1);
|
||
}
|
||
const meanRet = rets.reduce((a, b) => a + b, 0) / rets.length;
|
||
const variance = rets.reduce((a, b) => a + (b - meanRet) ** 2, 0) / rets.length;
|
||
const vol = Math.sqrt(variance) * Math.sqrt(252);
|
||
const sharpe = vol > 0 ? (meanRet * 252) / vol : 0;
|
||
const calmar = maxDD !== 0 ? cagr / Math.abs(maxDD) : 0;
|
||
|
||
// Rebalance count & avg holding days
|
||
const rebalCount = rebalanceDays.filter(i => i <= idx).length;
|
||
let avgHoldDays = '-';
|
||
if (rebalCount > 1) {
|
||
let totalHold = 0;
|
||
let prevRebal = rebalanceDays[0];
|
||
let cnt = 0;
|
||
for (const ri of rebalanceDays) {
|
||
if (ri > idx) break;
|
||
if (cnt > 0) totalHold += ri - prevRebal;
|
||
prevRebal = ri;
|
||
cnt++;
|
||
}
|
||
if (cnt > 1) avgHoldDays = (totalHold / (cnt - 1)).toFixed(1);
|
||
}
|
||
|
||
// Win rate (daily)
|
||
const winDays = rets.filter(r => r > 0).length;
|
||
const winRate = rets.length > 0 ? winDays / rets.length : 0;
|
||
|
||
// Year-to-date return
|
||
const curYear = days[idx].date.substring(0, 4);
|
||
let ytdReturn = null;
|
||
for (let i = idx; i >= 0; i--) {
|
||
if (days[i].date.substring(0, 4) !== curYear) {
|
||
const prevYearEndNav = days[i].nav;
|
||
if (prevYearEndNav && curNav) ytdReturn = curNav / prevYearEndNav - 1;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const items = [
|
||
{ label: 'CAGR', value: fmtPct(cagr), cls: retClass(cagr) },
|
||
{ label: '累计收益', value: fmtPct(totalReturn), cls: retClass(totalReturn) },
|
||
{ label: '最大回撤', value: fmtPct(maxDD), cls: 'negative' },
|
||
{ label: '年化波动', value: fmtPct(vol), cls: 'neutral' },
|
||
{ label: '夏普', value: fmtNum(sharpe, 3), cls: retClass(sharpe) },
|
||
{ label: 'Calmar', value: fmtNum(calmar, 3), cls: retClass(calmar) },
|
||
{ label: '胜率', value: fmtPct(winRate), cls: 'neutral' },
|
||
{ label: '调仓次数', value: String(rebalCount), cls: 'neutral' },
|
||
{ label: '平均持仓', value: avgHoldDays + '天', cls: 'neutral' },
|
||
{ label: '本年收益', value: ytdReturn !== null ? fmtPct(ytdReturn) : '-', cls: retClass(ytdReturn) },
|
||
{ label: '交易天数', value: String(tradingDays), cls: 'neutral' },
|
||
];
|
||
|
||
panel.innerHTML = items.map(it =>
|
||
`<div class="stat-item"><span class="stat-label">${it.label}</span><span class="stat-value ${it.cls}">${it.value}</span></div>`
|
||
).join('');
|
||
}
|
||
|
||
function renderHoldings(day, meta) {
|
||
const container = $('holdings-cards');
|
||
if (!day.holdings || day.holdings.length === 0) {
|
||
container.innerHTML = '<div style="color:#8b949e;padding:16px;text-align:center;">无持仓</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
// Sort holdings by weight descending (largest position first)
|
||
const sortedHoldings = [...day.holdings].sort((a, b) => {
|
||
const wa = day.assets[a] && day.assets[a].weight !== null ? day.assets[a].weight : 0;
|
||
const wb = day.assets[b] && day.assets[b].weight !== null ? day.assets[b].weight : 0;
|
||
return wb - wa;
|
||
});
|
||
for (const code of sortedHoldings) {
|
||
const a = day.assets[code];
|
||
if (!a) continue;
|
||
const info = meta.codes[code] || {};
|
||
const cumEtf = a.cum_return_etf;
|
||
const cumIdx = a.cum_return_idx;
|
||
const mainCum = cumEtf !== null && cumEtf !== undefined ? cumEtf : cumIdx;
|
||
const cls = mainCum !== null && mainCum !== undefined ? (mainCum >= 0 ? 'positive' : 'negative') : '';
|
||
|
||
html += `<div class="holding-card ${cls}">
|
||
<span class="code">${code}</span> <span class="name">${info.name || ''}</span>
|
||
${info.etf ? `<span class="name"> (${info.etf})</span>` : ''}
|
||
<div class="details">
|
||
<span class="label">入场日</span><span class="value">${a.entry_date || '-'}</span>
|
||
<span class="label">持有天数</span><span class="value">${a.holding_days || 0}</span>
|
||
<span class="label">仓位占比</span><span class="value" style="color:#f0c000;font-weight:600">${a.weight !== null && a.weight !== undefined ? fmtPct(a.weight) : '-'}</span>
|
||
<span class="label">大类</span><span class="value">${info.market || '-'}</span>
|
||
<span class="label">ETF累计</span><span class="value ${retClass(cumEtf)}">${fmtPct(cumEtf)}</span>
|
||
<span class="label">指数累计</span><span class="value ${retClass(cumIdx)}">${fmtPct(cumIdx)}</span>
|
||
<span class="label">今日ETF</span><span class="value ${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</span>
|
||
<span class="label">今日指数</span><span class="value ${retClass(a.index_return)}">${fmtPct(a.index_return)}</span>
|
||
<span class="label">动量</span><span class="value">${fmtNum(a.momentum, 4)}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderRanking(day, meta) {
|
||
const tbody = $('ranking-body');
|
||
const assets = day.assets;
|
||
const codes = Object.keys(assets);
|
||
|
||
// Sort by momentum descending, nulls last
|
||
codes.sort((a, b) => {
|
||
const ma = assets[a].momentum;
|
||
const mb = assets[b].momentum;
|
||
if (ma === null && mb === null) return 0;
|
||
if (ma === null) return 1;
|
||
if (mb === null) return -1;
|
||
return mb - ma;
|
||
});
|
||
|
||
const threshold = codes.length > 0 && assets[codes[0]].threshold !== null
|
||
? assets[codes[0]].threshold : 0;
|
||
|
||
let html = '';
|
||
let thresholdDrawn = false;
|
||
|
||
for (let i = 0; i < codes.length; i++) {
|
||
const code = codes[i];
|
||
const a = assets[code];
|
||
const info = meta.codes[code] || {};
|
||
const isHeld = a.is_held;
|
||
const belowThreshold = a.momentum !== null && a.momentum < threshold;
|
||
|
||
// Insert threshold separator row before first below-threshold item
|
||
if (!thresholdDrawn && belowThreshold) {
|
||
html += `<tr><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;border-bottom:2px dashed #da3633;padding:2px;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
|
||
thresholdDrawn = true;
|
||
}
|
||
|
||
let rowClass = '';
|
||
if (isHeld) rowClass += ' held';
|
||
if (belowThreshold) rowClass += ' below-threshold';
|
||
|
||
// Use rank from data, but show sequential for display
|
||
const displayRank = a.rank !== null ? a.rank : '-';
|
||
|
||
html += `<tr class="${rowClass}">
|
||
<td>${displayRank}</td>
|
||
<td>${code}</td>
|
||
<td>${info.name || ''}</td>
|
||
<td>${info.market || ''}</td>
|
||
<td>${fmtNum(a.momentum, 4)}</td>
|
||
<td>${fmtNum(threshold, 4)}</td>
|
||
<td>${fmtNum(a.index_close, 2)}</td>
|
||
<td>${a.etf_close !== null ? fmtNum(a.etf_close, 3) : '-'}</td>
|
||
<td class="${retClass(a.index_return)}">${fmtPct(a.index_return)}</td>
|
||
<td class="${retClass(a.etf_return_ctc)}">${fmtPct(a.etf_return_ctc)}</td>
|
||
<td class="${a.premium > 0.05 ? 'negative' : a.premium < -0.02 ? 'positive' : 'neutral'}">${a.premium !== null && a.premium !== undefined ? fmtPct(a.premium) : '-'}</td>
|
||
<td>${isHeld ? '<span style="color:#58a6ff">●</span>' : ''}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// If threshold line not drawn yet (all above), add it at the bottom
|
||
if (!thresholdDrawn && threshold > 0) {
|
||
html += `<tr class="threshold-line"><td colspan="12" style="color:#da3633;font-size:11px;text-align:center;">--- 动态阈值: ${fmtNum(threshold, 4)} ---</td></tr>`;
|
||
}
|
||
|
||
tbody.innerHTML = html;
|
||
}
|
||
|
||
function renderRebalanceBar(day, meta) {
|
||
const bar = $('rebalance-bar');
|
||
if (!day.is_rebalance) {
|
||
bar.classList.remove('active');
|
||
return;
|
||
}
|
||
bar.classList.add('active');
|
||
|
||
let html = '<strong>调仓:</strong>';
|
||
|
||
if (day.added.length > 0) {
|
||
html += ' 调入: ';
|
||
html += day.added.map(c => {
|
||
const name = (meta.codes[c] || {}).name || '';
|
||
return `<span class="tag-in">${c} (${name})</span>`;
|
||
}).join(' ');
|
||
}
|
||
|
||
if (day.removed.length > 0) {
|
||
html += ' 调出: ';
|
||
html += day.removed.map(c => {
|
||
const name = (meta.codes[c] || {}).name || '';
|
||
return `<span class="tag-out">${c} (${name})</span>`;
|
||
}).join(' ');
|
||
}
|
||
|
||
// Calculate turnover
|
||
const prevHoldings = currentIdx > 0 ? DATA.days[currentIdx - 1].holdings : [];
|
||
const swapped = day.removed.length;
|
||
const total = Math.max(prevHoldings.length, 1);
|
||
const turnover = swapped / total;
|
||
const cost = turnover * meta.trade_cost;
|
||
html += `<span style="color:#8b949e;margin-left:12px;">换手 ${fmtPct(turnover, 0)} | 成本 ${fmtPct(cost, 2)}</span>`;
|
||
|
||
bar.innerHTML = html;
|
||
}
|
||
|
||
// ==================== Chart ====================
|
||
let chartCanvas, chartCtx;
|
||
let chartData = { navs: [], dates: [], rebalIdx: [] };
|
||
|
||
function drawChart() {
|
||
chartCanvas = $('nav-chart');
|
||
chartCtx = chartCanvas.getContext('2d');
|
||
|
||
// Retina support
|
||
const rect = chartCanvas.parentElement.getBoundingClientRect();
|
||
const dpr = window.devicePixelRatio || 1;
|
||
chartCanvas.width = rect.width * dpr;
|
||
chartCanvas.height = rect.height * dpr;
|
||
chartCtx.scale(dpr, dpr);
|
||
chartCanvas.style.width = rect.width + 'px';
|
||
chartCanvas.style.height = rect.height + 'px';
|
||
|
||
chartData.navs = DATA.days.map(d => d.nav);
|
||
chartData.dates = DATA.days.map(d => d.date);
|
||
chartData.rebalIdx = rebalanceDays;
|
||
|
||
drawChartFull();
|
||
drawChartMarker();
|
||
}
|
||
|
||
function drawChartFull() {
|
||
const ctx = chartCtx;
|
||
const W = chartCanvas.width / (window.devicePixelRatio || 1);
|
||
const H = chartCanvas.height / (window.devicePixelRatio || 1);
|
||
const pad = { top: 20, right: 60, bottom: 20, left: 10 };
|
||
const cw = W - pad.left - pad.right;
|
||
const ch = H - pad.top - pad.bottom;
|
||
|
||
const navs = chartData.navs;
|
||
const minNav = Math.min(...navs.filter(v => v !== null)) * 0.98;
|
||
const maxNav = Math.max(...navs.filter(v => v !== null)) * 1.02;
|
||
const n = navs.length;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Grid lines
|
||
ctx.strokeStyle = '#21262d';
|
||
ctx.lineWidth = 0.5;
|
||
for (let i = 0; i <= 4; i++) {
|
||
const y = pad.top + ch * i / 4;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pad.left, y);
|
||
ctx.lineTo(W - pad.right, y);
|
||
ctx.stroke();
|
||
const val = maxNav - (maxNav - minNav) * i / 4;
|
||
ctx.fillStyle = '#484f58';
|
||
ctx.font = '10px monospace';
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(val.toFixed(2), W - pad.right + 4, y + 3);
|
||
}
|
||
|
||
// Nav line
|
||
ctx.strokeStyle = '#58a6ff';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.beginPath();
|
||
for (let i = 0; i < n; i++) {
|
||
const x = pad.left + (i / (n - 1)) * cw;
|
||
const y = pad.top + (1 - (navs[i] - minNav) / (maxNav - minNav)) * ch;
|
||
if (i === 0) ctx.moveTo(x, y);
|
||
else ctx.lineTo(x, y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
// Rebalance dots
|
||
ctx.fillStyle = 'rgba(218, 54, 51, 0.6)';
|
||
for (const ri of chartData.rebalIdx) {
|
||
const x = pad.left + (ri / (n - 1)) * cw;
|
||
const y = pad.top + (1 - (navs[ri] - minNav) / (maxNav - minNav)) * ch;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
// Store layout for marker
|
||
chartData.layout = { pad, cw, ch, minNav, maxNav, n, W, H };
|
||
}
|
||
|
||
function drawChartMarker() {
|
||
if (!chartData.layout) return;
|
||
const { pad, cw, ch, minNav, maxNav, n, W, H } = chartData.layout;
|
||
|
||
// Redraw full chart then add marker
|
||
drawChartFull();
|
||
|
||
const ctx = chartCtx;
|
||
const x = pad.left + (currentIdx / (n - 1)) * cw;
|
||
|
||
// Vertical line
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
|
||
ctx.lineWidth = 1;
|
||
ctx.setLineDash([3, 3]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, pad.top);
|
||
ctx.lineTo(x, pad.top + ch);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Dot
|
||
const nav = chartData.navs[currentIdx];
|
||
const y = pad.top + (1 - (nav - minNav) / (maxNav - minNav)) * ch;
|
||
ctx.fillStyle = '#fff';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 4, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = '#fff';
|
||
ctx.font = '11px monospace';
|
||
ctx.textAlign = x > W / 2 ? 'right' : 'left';
|
||
const labelX = x > W / 2 ? x - 8 : x + 8;
|
||
ctx.fillText(fmtNum(nav, 4), labelX, y - 8);
|
||
}
|
||
|
||
// ==================== Chart interaction (click & drag) ====================
|
||
let isDragging = false;
|
||
|
||
function xToIndex(clientX) {
|
||
if (!chartData.layout) return -1;
|
||
const rect = chartCanvas.getBoundingClientRect();
|
||
const { pad, cw, n } = chartData.layout;
|
||
const x = clientX - rect.left - pad.left;
|
||
const ratio = Math.max(0, Math.min(1, x / cw));
|
||
return Math.round(ratio * (n - 1));
|
||
}
|
||
|
||
function chartNavigateTo(clientX) {
|
||
const idx = xToIndex(clientX);
|
||
if (idx >= 0 && idx < DATA.days.length && idx !== currentIdx) {
|
||
currentIdx = idx;
|
||
render(currentIdx);
|
||
drawChartMarker();
|
||
}
|
||
}
|
||
|
||
$('nav-chart').addEventListener('mousedown', e => {
|
||
if (!DATA) return;
|
||
isDragging = true;
|
||
chartNavigateTo(e.clientX);
|
||
});
|
||
|
||
window.addEventListener('mousemove', e => {
|
||
if (!isDragging) return;
|
||
chartNavigateTo(e.clientX);
|
||
});
|
||
|
||
window.addEventListener('mouseup', () => {
|
||
isDragging = false;
|
||
});
|
||
|
||
// Touch support
|
||
$('nav-chart').addEventListener('touchstart', e => {
|
||
if (!DATA) return;
|
||
isDragging = true;
|
||
chartNavigateTo(e.touches[0].clientX);
|
||
}, { passive: true });
|
||
|
||
window.addEventListener('touchmove', e => {
|
||
if (!isDragging) return;
|
||
chartNavigateTo(e.touches[0].clientX);
|
||
}, { passive: true });
|
||
|
||
window.addEventListener('touchend', () => {
|
||
isDragging = false;
|
||
});
|
||
|
||
// Cursor style
|
||
$('nav-chart').style.cursor = 'crosshair';
|
||
|
||
// Resize handler
|
||
window.addEventListener('resize', () => {
|
||
if (DATA) { drawChart(); }
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|