feat(portfolio): 组合回测结果增强: 基准对比曲线(对齐交易日+归一化)+回撤序列+扩展指标(波动/Sortino/Calmar/超额/Alpha/Beta), worker与API透传, 结果页净值对比+回撤图 [vps]
This commit is contained in:
@@ -13,6 +13,8 @@ import {
|
||||
type StockPicked,
|
||||
type PortfolioTrade,
|
||||
type PortfolioMetrics,
|
||||
type BenchmarkPoint,
|
||||
type DrawdownPoint,
|
||||
} from '@/api/portfolio'
|
||||
|
||||
// 两态:表单(发起) / 查看(route.query.task_id 历史结果)。任务跟踪统一在「历史任务」页(任务中心)。
|
||||
@@ -21,6 +23,8 @@ const router = useRouter()
|
||||
const viewTaskId = computed(() => (route.query.task_id as string) || '')
|
||||
const result = ref<PortfolioBacktestResult | null>(null)
|
||||
const equityCurve = ref<EquityPoint[]>([])
|
||||
const benchmarkCurve = ref<BenchmarkPoint[]>([])
|
||||
const drawdownCurve = ref<DrawdownPoint[]>([])
|
||||
const stocks = ref<StockPicked[]>([])
|
||||
const trades = ref<PortfolioTrade[]>([])
|
||||
const metrics = ref<PortfolioMetrics | null>(null)
|
||||
@@ -91,23 +95,57 @@ const { setOption: setEquityOption } = useChart(equityEl)
|
||||
function renderEquity(): void {
|
||||
if (!equityCurve.value.length) return
|
||||
const dates = equityCurve.value.map((p) => p.date)
|
||||
const values = equityCurve.value.map((p) => p.equity)
|
||||
// 策略净值归一化(首日=1),与基准同轴对比
|
||||
const base = equityCurve.value[0]?.equity || 1
|
||||
const values = equityCurve.value.map((p) => p.equity / base)
|
||||
const hasBench = benchmarkCurve.value.length === equityCurve.value.length
|
||||
const series: EChartsCoreOption['series'] = [
|
||||
{
|
||||
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values.map((v) => Number(v.toFixed(4))),
|
||||
},
|
||||
]
|
||||
if (hasBench) {
|
||||
series.push({
|
||||
type: 'line', name: '基准净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#4b7bce', width: 1.4, type: 'dashed' },
|
||||
data: benchmarkCurve.value.map((p) => Number((p.benchmark ?? 1).toFixed(4))),
|
||||
})
|
||||
}
|
||||
const option: EChartsCoreOption = {
|
||||
title: darkTitle('净值曲线'),
|
||||
tooltip: darkTooltip(),
|
||||
title: darkTitle(hasBench ? '净值对比(归一化)' : '净值曲线'),
|
||||
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', scale: true, name: '净值(元)', ...darkAxis() },
|
||||
yAxis: { type: 'value', scale: true, name: '净值', ...darkAxis() },
|
||||
series,
|
||||
}
|
||||
setEquityOption(option)
|
||||
}
|
||||
|
||||
const ddEl = ref<HTMLDivElement>()
|
||||
const { setOption: setDdOption } = useChart(ddEl)
|
||||
function renderDrawdown(): void {
|
||||
if (!drawdownCurve.value.length) return
|
||||
const option: EChartsCoreOption = {
|
||||
title: darkTitle('回撤(%)'),
|
||||
tooltip: { ...darkTooltip(), trigger: 'axis' },
|
||||
grid: darkGrid(),
|
||||
xAxis: { type: 'category', data: drawdownCurve.value.map((p) => p.date), ...darkAxis() },
|
||||
yAxis: { type: 'value', name: '回撤%', ...darkAxis() },
|
||||
series: [
|
||||
{
|
||||
type: 'line', name: '策略净值', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#c23531', width: 1.6 },
|
||||
areaStyle: { color: '#c23531', opacity: 0.12 },
|
||||
data: values,
|
||||
type: 'line', name: '回撤', smooth: true, showSymbol: false,
|
||||
lineStyle: { color: '#6f42c1', width: 1.4 },
|
||||
areaStyle: { color: '#6f42c1', opacity: 0.18 },
|
||||
data: drawdownCurve.value.map((p) => Number((p.drawdown ?? 0).toFixed(2))),
|
||||
},
|
||||
],
|
||||
}
|
||||
setEquityOption(option)
|
||||
setDdOption(option)
|
||||
}
|
||||
|
||||
async function loadResult(tid: string): Promise<void> {
|
||||
@@ -115,12 +153,15 @@ async function loadResult(tid: string): Promise<void> {
|
||||
const r = await getPortfolioResult(tid)
|
||||
result.value = r
|
||||
equityCurve.value = r.equity_curve || []
|
||||
benchmarkCurve.value = r.benchmark_curve || []
|
||||
drawdownCurve.value = r.drawdown_curve || []
|
||||
stocks.value = r.stocks_selected || []
|
||||
trades.value = r.trades || []
|
||||
metrics.value = r.metrics || null
|
||||
period.value = r.period || null
|
||||
await nextTick()
|
||||
renderEquity()
|
||||
renderDrawdown()
|
||||
} catch {
|
||||
ElMessage.error('加载结果失败')
|
||||
}
|
||||
@@ -154,6 +195,7 @@ onMounted(() => {
|
||||
if (viewTaskId.value) loadResult(viewTaskId.value)
|
||||
})
|
||||
watch(equityCurve, renderEquity, { deep: true, flush: 'post' })
|
||||
watch(drawdownCurve, renderDrawdown, { deep: true, flush: 'post' })
|
||||
|
||||
function fmtPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined || !Number.isFinite(v)) return '-'
|
||||
@@ -269,14 +311,25 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
<div class="metric-cell"><div class="metric-label">夏普比率</div><div class="metric-value">{{ fmtNum(metrics?.sharpe) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">日胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_daily) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">交易胜率</div><div class="metric-value">{{ fmtPct(metrics?.win_rate_trade) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">年化波动</div><div class="metric-value">{{ fmtPct(metrics?.annual_volatility) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Sortino</div><div class="metric-value">{{ fmtNum(metrics?.sortino) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Calmar</div><div class="metric-value">{{ fmtNum(metrics?.calmar) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">基准收益</div><div class="metric-value" :class="(metrics?.benchmark_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.benchmark_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">超额收益</div><div class="metric-value" :class="(metrics?.excess_return ?? 0) >= 0 ? 'up' : 'down'">{{ fmtPct(metrics?.excess_return) }}</div></div>
|
||||
<div class="metric-cell"><div class="metric-label">Alpha / Beta</div><div class="metric-value">{{ fmtNum(metrics?.alpha, 2) }} / {{ fmtNum(metrics?.beta, 2) }}</div></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">净值曲线</span></template>
|
||||
<template #header><span class="section-title">净值对比</span></template>
|
||||
<div ref="equityEl" class="chart-box" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result && drawdownCurve.length" class="blk" shadow="never">
|
||||
<template #header><span class="section-title">回撤曲线</span></template>
|
||||
<div ref="ddEl" 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="无持仓数据">
|
||||
@@ -313,6 +366,7 @@ function fmtNum(v: number | null | undefined, digits = 2): string {
|
||||
.submit-bar { padding: 4px 0 8px; }
|
||||
.result-actions { display: flex; gap: 8px; padding: 0 0 4px; }
|
||||
.chart-box { width: 100%; height: 360px; }
|
||||
.dd-box { height: 240px; }
|
||||
.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