Files
etf/rotation/backtest_viewer.html

722 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="上一天 (←)">&#9664; 前一天</button>
<button id="btn-next" title="下一天 (→)">后一天 &#9654;</button>
<button id="btn-prev-rebal" title="上一调仓日">&#9664;&#9664; 上一调仓</button>
<button id="btn-next-rebal" title="下一调仓日">下一调仓 &#9654;&#9654;</button>
<input type="date" id="date-picker">
<button id="btn-play" title="播放/暂停 (Space)">&#9654; 播放</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>
let DATA = null;
let currentIdx = 0;
let playing = false;
let playTimer = null;
let rebalanceDays = [];
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 ? '&#9646;&#9646; 暂停' : '&#9654; 播放';
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 = '&#9654; 播放';
}
}
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">&#9679;</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>