feat(portfolio): 组合回测结果页加月度收益热力图(年×月+年度合计)+滚动夏普/波动双轴图(63日窗,业界标准);前端从equity_curve算零后端改动;strategy query预填 [nas]
This commit is contained in:
@@ -148,6 +148,108 @@ function renderDrawdown(): void {
|
||||
setDdOption(option)
|
||||
}
|
||||
|
||||
// 滚动指标(业界标准:滚动窗口年化波动/夏普,看稳定性而非单值)
|
||||
const ROLLING_WINDOW = 63 // ≈ 一个季度
|
||||
const rollEl = ref<HTMLDivElement>()
|
||||
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<string, number | null> // `${year}-${month}` -> 月收益(小数)
|
||||
yearTotals: Record<number, number | null>
|
||||
}
|
||||
const monthly = computed<MonthlyTable | null>(() => {
|
||||
const curve = equityCurve.value
|
||||
if (curve.length < 2) return null
|
||||
const monthLast = new Map<string, number>()
|
||||
const monthFirst = new Map<string, number>()
|
||||
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<string, number | null> = {}
|
||||
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<number, number | null> = {}
|
||||
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<void> {
|
||||
try {
|
||||
const r = await getPortfolioResult(tid)
|
||||
@@ -162,6 +264,7 @@ async function loadResult(tid: string): Promise<void> {
|
||||
await nextTick()
|
||||
renderEquity()
|
||||
renderDrawdown()
|
||||
renderRolling()
|
||||
} catch {
|
||||
ElMessage.error('加载结果失败')
|
||||
}
|
||||
@@ -192,7 +295,12 @@ async function onSubmit(): Promise<void> {
|
||||
}
|
||||
|
||||
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 {
|
||||
<div ref="ddEl" class="chart-box dd-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result && monthly" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">月度收益热力图(%)</span></template>
|
||||
<div class="heat-wrap">
|
||||
<table class="heat-table mono">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>年份</th>
|
||||
<th v-for="(m, i) in MONTH_LABELS" :key="i">{{ m }}</th>
|
||||
<th>年度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="y in monthly.years" :key="y">
|
||||
<td class="heat-year">{{ y }}</td>
|
||||
<td
|
||||
v-for="m in 12" :key="m"
|
||||
class="heat-cell"
|
||||
:style="{ background: cellColor(monthly.cells[monthKey(y, m)]) }"
|
||||
>{{ fmtMonth(monthly.cells[monthKey(y, m)]) }}</td>
|
||||
<td
|
||||
class="heat-cell heat-year-total"
|
||||
:style="{ background: cellColor(monthly.yearTotals[y]) }"
|
||||
>{{ fmtMonth(monthly.yearTotals[y]) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result && equityCurve.length > 64" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">滚动风险指标</span></template>
|
||||
<div ref="rollEl" class="chart-box dd-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">选股名单(末日持仓)</span></template>
|
||||
<el-table :data="stocks" stripe size="small" empty-text="无持仓数据">
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user