From 819198919cf89124411454458d57e54b0926995b Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 13 Aug 2026 19:40:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(portfolio):=20=E7=BB=84=E5=90=88=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B=E7=BB=93=E6=9E=9C=E9=A1=B5=E5=8A=A0=E6=9C=88=E5=BA=A6?= =?UTF-8?q?=E6=94=B6=E7=9B=8A=E7=83=AD=E5=8A=9B=E5=9B=BE(=E5=B9=B4=C3=97?= =?UTF-8?q?=E6=9C=88+=E5=B9=B4=E5=BA=A6=E5=90=88=E8=AE=A1)+=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E5=A4=8F=E6=99=AE/=E6=B3=A2=E5=8A=A8=E5=8F=8C?= =?UTF-8?q?=E8=BD=B4=E5=9B=BE(63=E6=97=A5=E7=AA=97,=E4=B8=9A=E7=95=8C?= =?UTF-8?q?=E6=A0=87=E5=87=86);=E5=89=8D=E7=AB=AF=E4=BB=8Eequity=5Fcurve?= =?UTF-8?q?=E7=AE=97=E9=9B=B6=E5=90=8E=E7=AB=AF=E6=94=B9=E5=8A=A8;strategy?= =?UTF-8?q?=20query=E9=A2=84=E5=A1=AB=20[nas]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/backtest/PortfolioBacktest.vue | 151 +++++++++++++++++- 1 file changed, 150 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/backtest/PortfolioBacktest.vue b/frontend/src/views/backtest/PortfolioBacktest.vue index 903b8f9..b63f56c 100644 --- a/frontend/src/views/backtest/PortfolioBacktest.vue +++ b/frontend/src/views/backtest/PortfolioBacktest.vue @@ -148,6 +148,108 @@ function renderDrawdown(): void { setDdOption(option) } +// 滚动指标(业界标准:滚动窗口年化波动/夏普,看稳定性而非单值) +const ROLLING_WINDOW = 63 // ≈ 一个季度 +const rollEl = ref() +const { setOption: setRollOption } = useChart(rollEl) +function renderRolling(): void { + const curve = equityCurve.value + if (curve.length < ROLLING_WINDOW + 1) return + const rets: number[] = [] + for (let i = 1; i < curve.length; i++) { + rets.push(curve[i].equity / curve[i - 1].equity - 1) + } + const ann = Math.sqrt(252) + const dates: string[] = [] + const vols: number[] = [] + const sharpes: number[] = [] + for (let i = ROLLING_WINDOW - 1; i < rets.length; i++) { + const w = rets.slice(i - ROLLING_WINDOW + 1, i + 1) + const mean = w.reduce((a, b) => a + b, 0) / w.length + const variance = w.reduce((a, b) => a + (b - mean) ** 2, 0) / (w.length - 1) + const sd = Math.sqrt(variance) + dates.push(curve[i + 1].date) + vols.push(Number((sd * ann * 100).toFixed(2))) + sharpes.push(sd > 0 ? Number(((mean / sd) * ann).toFixed(2)) : 0) + } + const option: EChartsCoreOption = { + title: darkTitle(`滚动夏普 / 波动(${ROLLING_WINDOW}日)`), + tooltip: { ...darkTooltip(), trigger: 'axis' }, + legend: { ...darkAxis(), top: 4, right: 8, textStyle: { color: 'var(--text-3, #aaa)' } }, + grid: darkGrid(), + xAxis: { type: 'category', data: dates, ...darkAxis() }, + yAxis: [ + { type: 'value', name: '波动%', scale: true, ...darkAxis() }, + { type: 'value', name: '夏普', scale: true, ...darkAxis() }, + ], + series: [ + { + type: 'line', name: '滚动年化波动%', smooth: true, showSymbol: false, yAxisIndex: 0, + lineStyle: { color: '#e0a458', width: 1.4 }, + areaStyle: { color: '#e0a458', opacity: 0.10 }, + data: vols, + }, + { + type: 'line', name: '滚动夏普', smooth: true, showSymbol: false, yAxisIndex: 1, + lineStyle: { color: '#36cfc9', width: 1.4 }, + data: sharpes, + }, + ], + } + setRollOption(option) +} + +// 月度收益热力图(前端从净值算:月末净值环比,首月为部分月) +interface MonthlyTable { + years: number[] + cells: Record // `${year}-${month}` -> 月收益(小数) + yearTotals: Record +} +const monthly = computed(() => { + const curve = equityCurve.value + if (curve.length < 2) return null + const monthLast = new Map() + const monthFirst = new Map() + for (const p of curve) { + const key = p.date.slice(0, 7) + if (!monthFirst.has(key)) monthFirst.set(key, p.equity) + monthLast.set(key, p.equity) + } + const keys = Array.from(monthLast.keys()).sort() + const cells: Record = {} + for (let i = 0; i < keys.length; i++) { + const prev = i > 0 ? monthLast.get(keys[i - 1]) : monthFirst.get(keys[i]) + const cur = monthLast.get(keys[i]) + if (prev == null || cur == null || prev <= 0) continue + cells[keys[i]] = cur / prev - 1 + } + const years: number[] = [] + const yearTotals: Record = {} + for (const y of Array.from(new Set(keys.map((k) => Number(k.slice(0, 4))))).sort()) { + years.push(y) + const rets = Array.from({ length: 12 }, (_, m) => cells[`${y}-${String(m + 1).padStart(2, '0')}`]) + .filter((v): v is number => v != null) + yearTotals[y] = rets.length + ? rets.reduce((acc, r) => acc * (1 + r), 1) - 1 + : null + } + return { years, cells, yearTotals } +}) +const MONTH_LABELS = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'] +function monthKey(y: number, m: number): string { + return `${y}-${String(m).padStart(2, '0')}` +} +function cellColor(v: number | null | undefined): string { + if (v == null) return 'transparent' + const intensity = Math.min(Math.abs(v) / 0.08, 1) // 8% 封顶 + const alpha = 0.12 + intensity * 0.55 + return v >= 0 ? `rgba(245, 108, 108, ${alpha.toFixed(2)})` : `rgba(103, 194, 58, ${alpha.toFixed(2)})` +} +function fmtMonth(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '' + return (v * 100).toFixed(1) +} + async function loadResult(tid: string): Promise { try { const r = await getPortfolioResult(tid) @@ -162,6 +264,7 @@ async function loadResult(tid: string): Promise { await nextTick() renderEquity() renderDrawdown() + renderRolling() } catch { ElMessage.error('加载结果失败') } @@ -192,7 +295,12 @@ async function onSubmit(): Promise { } onMounted(() => { - if (viewTaskId.value) loadResult(viewTaskId.value) + if (viewTaskId.value) { + loadResult(viewTaskId.value) + } else if (route.query.strategy) { + // 从代码编辑页「运行回测」跳转:按策略预填 + form.strategy = String(route.query.strategy) + } }) watch(equityCurve, renderEquity, { deep: true, flush: 'post' }) watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' }) @@ -330,6 +438,40 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
+ + +
+ + + + + + + + + + + + + + + +
年份{{ m }}年度
{{ y }}{{ fmtMonth(monthly.cells[monthKey(y, m)]) }}{{ fmtMonth(monthly.yearTotals[y]) }}
+
+
+ + + +
+ + @@ -367,6 +509,13 @@ function fmtNum(v: number | null | undefined, digits = 2): string { .result-actions { display: flex; gap: 8px; padding: 0 0 4px; } .chart-box { width: 100%; height: 360px; } .dd-box { height: 240px; } +.heat-wrap { overflow-x: auto; } +.heat-table { border-collapse: collapse; width: 100%; font-size: 12px; } +.heat-table th { color: var(--text-3); font-weight: 500; padding: 6px 8px; text-align: center; border-bottom: 1px solid var(--border-2); } +.heat-table td { padding: 7px 8px; text-align: center; } +.heat-year { color: var(--text-2); font-weight: 600; text-align: left; } +.heat-cell { border: 1px solid var(--border-2); border-radius: 3px; color: var(--text); } +.heat-year-total { font-weight: 700; } .metric-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; } .metric-cell { background: var(--bg-hover); border: 1px solid var(--border-2); border-radius: 6px; padding: 12px 14px; } .metric-label { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }