feat(frontend): 结果页组件—MetricCards指标卡+5图(基准/Alpha/Beta/波动率/回撤)+API封装

This commit is contained in:
2026-07-11 13:54:05 +08:00
parent 304844903c
commit d0315ccbdf
13 changed files with 683 additions and 0 deletions
+44
View File
@@ -129,3 +129,47 @@ export async function getOptimizationResults(taskId: string): Promise<OptRow[]>
return data.results
}
// ----- Task 5+6: Backtest result page components -----
export interface RelativeMetrics {
total_return: number
annual_return: number
alpha: number
beta: number
sharpe_ratio: number
sortino_ratio: number
information_ratio: number
annual_volatility: number
max_drawdown: number
benchmark_return: number
benchmark_volatility: number
}
export interface BenchmarkCurveData {
dates: string[]
strategy: number[]
benchmark: number[]
}
export interface RiskSeriesData {
dates: string[]
alpha: number[]
beta: number[]
drawdown: number[]
}
export async function getRelativeMetrics(taskId: string): Promise<RelativeMetrics> {
const { data } = await apiClient.get<{ relative_metrics: RelativeMetrics }>(`/task/${taskId}/result`)
return data.relative_metrics
}
export async function getBenchmarkCurve(taskId: string): Promise<BenchmarkCurveData> {
const { data } = await apiClient.get<BenchmarkCurveData>(`/task/${taskId}/benchmark-curve`)
return data
}
export async function getRiskSeries(taskId: string): Promise<RiskSeriesData> {
const { data } = await apiClient.get<RiskSeriesData>(`/task/${taskId}/risk-series`)
return data
}
@@ -0,0 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import AlphaChart from './AlphaChart.vue'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
describe('AlphaChart.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chart container without crashing', () => {
const props = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
alpha: [0.02, 0.025, 0.03],
}
const wrapper = mount(AlphaChart, { props })
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
it('passes props correctly', () => {
const props = {
dates: ['2024-01-01', '2024-01-02'],
alpha: [0.02, 0.025],
}
const wrapper = mount(AlphaChart, { props })
expect(wrapper.props()).toEqual(props)
})
})
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const ALPHA = '#61a0a8' // Alpha 绿
const props = defineProps<{ dates: string[]; alpha: number[] }>()
const el = ref<HTMLDivElement>()
const { setOption } = useChart(el)
function render(): void {
if (!props.dates.length || !props.alpha.length) return
const option: EChartsCoreOption = {
title: darkTitle('Alpha'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: {
type: 'category',
data: props.dates,
...darkAxis(),
},
yAxis: {
type: 'value',
scale: true,
name: 'Alpha',
...darkAxis(),
},
series: [
{
type: 'line',
name: 'Alpha',
smooth: true,
showSymbol: false,
lineStyle: { color: ALPHA, width: 1.6 },
areaStyle: { color: ALPHA, opacity: 0.12 },
data: props.alpha,
},
],
}
setOption(option)
}
onMounted(render)
watch(() => [props.dates, props.alpha], render, { deep: true })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 280px; }</style>
@@ -0,0 +1,40 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import BenchmarkCurve from './BenchmarkCurve.vue'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
describe('BenchmarkCurve.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chart container without crashing', () => {
const props = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
strategy: [1.0, 1.02, 1.05],
benchmark: [1.0, 1.01, 1.03],
}
const wrapper = mount(BenchmarkCurve, { props })
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
it('passes props correctly', () => {
const props = {
dates: ['2024-01-01', '2024-01-02'],
strategy: [1.0, 1.02],
benchmark: [1.0, 1.01],
}
const wrapper = mount(BenchmarkCurve, { props })
expect(wrapper.props()).toEqual(props)
})
})
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const STRATEGY = '#c23531' // 策略红
const BENCHMARK = '#2f4554' // 基准蓝
const props = defineProps<{ dates: string[]; strategy: number[]; benchmark: number[] }>()
const el = ref<HTMLDivElement>()
const { setOption } = useChart(el)
function render(): void {
if (!props.dates.length || !props.strategy.length || !props.benchmark.length) return
const option: EChartsCoreOption = {
title: darkTitle('基准曲线对比'),
tooltip: darkTooltip(),
grid: darkGrid(),
legend: {
data: ['策略', '基准'],
textStyle: { color: '#e6edf3', fontSize: 12 },
top: 24,
},
xAxis: {
type: 'category',
data: props.dates,
...darkAxis(),
},
yAxis: {
type: 'value',
scale: true,
name: '净值',
...darkAxis(),
},
series: [
{
type: 'line',
name: '策略',
smooth: true,
showSymbol: false,
lineStyle: { color: STRATEGY, width: 1.6 },
areaStyle: { color: STRATEGY, opacity: 0.12 },
data: props.strategy,
},
{
type: 'line',
name: '基准',
smooth: true,
showSymbol: false,
lineStyle: { color: BENCHMARK, width: 1.6 },
areaStyle: { color: BENCHMARK, opacity: 0.12 },
data: props.benchmark,
},
],
}
setOption(option)
}
onMounted(render)
watch(() => [props.dates, props.strategy, props.benchmark], render, { deep: true })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 320px; }</style>
@@ -0,0 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import BetaChart from './BetaChart.vue'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
describe('BetaChart.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chart container without crashing', () => {
const props = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
beta: [0.98, 0.99, 1.01],
}
const wrapper = mount(BetaChart, { props })
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
it('passes props correctly', () => {
const props = {
dates: ['2024-01-01', '2024-01-02'],
beta: [0.98, 0.99],
}
const wrapper = mount(BetaChart, { props })
expect(wrapper.props()).toEqual(props)
})
})
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const BETA = '#61a0a8' // Beta 绿
const props = defineProps<{ dates: string[]; beta: number[] }>()
const el = ref<HTMLDivElement>()
const { setOption } = useChart(el)
function render(): void {
if (!props.dates.length || !props.beta.length) return
const option: EChartsCoreOption = {
title: darkTitle('Beta'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: {
type: 'category',
data: props.dates,
...darkAxis(),
},
yAxis: {
type: 'value',
scale: true,
name: 'Beta',
...darkAxis(),
},
series: [
{
type: 'line',
name: 'Beta',
smooth: true,
showSymbol: false,
lineStyle: { color: BETA, width: 1.6 },
areaStyle: { color: BETA, opacity: 0.12 },
data: props.beta,
},
],
}
setOption(option)
}
onMounted(render)
watch(() => [props.dates, props.beta], render, { deep: true })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 280px; }</style>
@@ -0,0 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import DrawdownChart from './DrawdownChart.vue'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
describe('DrawdownChart.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chart container without crashing', () => {
const props = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
drawdown: [0.0, -0.02, -0.08],
}
const wrapper = mount(DrawdownChart, { props })
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
it('passes props correctly', () => {
const props = {
dates: ['2024-01-01', '2024-01-02'],
drawdown: [0.0, -0.02],
}
const wrapper = mount(DrawdownChart, { props })
expect(wrapper.props()).toEqual(props)
})
})
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const DRAWDOWN = '#d48265' // 回撤橙
const props = defineProps<{ dates: string[]; drawdown: number[] }>()
const el = ref<HTMLDivElement>()
const { setOption } = useChart(el)
function render(): void {
if (!props.dates.length || !props.drawdown.length) return
const option: EChartsCoreOption = {
title: darkTitle('回撤'),
tooltip: darkTooltip(),
grid: darkGrid(),
xAxis: {
type: 'category',
data: props.dates,
...darkAxis(),
},
yAxis: {
type: 'value',
scale: true,
name: '回撤',
...darkAxis(),
},
series: [
{
type: 'line',
name: '回撤',
smooth: true,
showSymbol: false,
lineStyle: { color: DRAWDOWN, width: 1.6 },
areaStyle: { color: DRAWDOWN, opacity: 0.3 },
data: props.drawdown,
},
],
}
setOption(option)
}
onMounted(render)
watch(() => [props.dates, props.drawdown], render, { deep: true })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 280px; }</style>
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import MetricCards from './MetricCards.vue'
describe('MetricCards.vue', () => {
it('renders 10 metric cards with correct values', () => {
const metrics = {
total_return: 0.1532,
annual_return: 0.0821,
alpha: 0.0245,
beta: 0.98,
sharpe_ratio: 1.23,
sortino_ratio: 1.45,
information_ratio: 0.67,
annual_volatility: 0.12,
max_drawdown: -0.0824,
benchmark_return: 0.0650,
benchmark_volatility: 0.11,
}
const wrapper = mount(MetricCards, {
props: { metrics },
})
const text = wrapper.text()
// Check percentage formatting (×100, 2 decimals)
expect(text).toContain('15.32%') // total_return
expect(text).toContain('8.21%') // annual_return
expect(text).toContain('2.45%') // alpha
expect(text).toContain('0.98') // beta (not percentage)
expect(text).toContain('1.23') // sharpe_ratio
expect(text).toContain('1.45') // sortino_ratio
expect(text).toContain('0.67') // information_ratio
expect(text).toContain('12.00%') // annual_volatility
expect(text).toContain('-8.24%') // max_drawdown
expect(text).toContain('6.50%') // benchmark_return
expect(text).toContain('11.00%') // benchmark_volatility
})
it('renders all metric labels', () => {
const metrics = {
total_return: 0.1,
annual_return: 0.1,
alpha: 0.1,
beta: 1.0,
sharpe_ratio: 1.0,
sortino_ratio: 1.0,
information_ratio: 0.5,
annual_volatility: 0.1,
max_drawdown: -0.05,
benchmark_return: 0.08,
benchmark_volatility: 0.1,
}
const wrapper = mount(MetricCards, {
props: { metrics },
})
const text = wrapper.text()
// Check all metric names are present
expect(text).toContain('总收益率')
expect(text).toContain('年化收益率')
expect(text).toContain('Alpha')
expect(text).toContain('Beta')
expect(text).toContain('Sharpe比率')
expect(text).toContain('Sortino比率')
expect(text).toContain('信息比率')
expect(text).toContain('年化波动率')
expect(text).toContain('最大回撤')
expect(text).toContain('基准收益率')
expect(text).toContain('基准波动率')
})
})
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
interface RelativeMetrics {
total_return: number
annual_return: number
alpha: number
beta: number
sharpe_ratio: number
sortino_ratio: number
information_ratio: number
annual_volatility: number
max_drawdown: number
benchmark_return: number
benchmark_volatility: number
}
const props = defineProps<{ metrics: RelativeMetrics }>()
// 格式化百分比(×100,保留2位小数)
function formatPercent(value: number): string {
return `${(value * 100).toFixed(2)}%`
}
// 格式化小数(保留3位小数)
function formatDecimal(value: number): string {
return value.toFixed(3)
}
// 格式化Sharpe等比率(保留2位小数)
function formatRatio(value: number): string {
return value.toFixed(2)
}
const metricCards = computed(() => [
{ label: '总收益率', value: formatPercent(props.metrics.total_return) },
{ label: '年化收益率', value: formatPercent(props.metrics.annual_return) },
{ label: 'Alpha', value: formatPercent(props.metrics.alpha) },
{ label: 'Beta', value: formatDecimal(props.metrics.beta) },
{ label: 'Sharpe比率', value: formatRatio(props.metrics.sharpe_ratio) },
{ label: 'Sortino比率', value: formatRatio(props.metrics.sortino_ratio) },
{ label: '信息比率', value: formatRatio(props.metrics.information_ratio) },
{ label: '年化波动率', value: formatPercent(props.metrics.annual_volatility) },
{ label: '最大回撤', value: formatPercent(props.metrics.max_drawdown) },
{ label: '基准收益率', value: formatPercent(props.metrics.benchmark_return) },
{ label: '基准波动率', value: formatPercent(props.metrics.benchmark_volatility) },
])
</script>
<template>
<div class="metric-cards">
<div v-for="card in metricCards" :key="card.label" class="metric-card">
<div class="metric-label">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</div>
</div>
</template>
<style scoped>
.metric-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.metric-label {
color: #8b949e;
font-size: 12px;
}
.metric-value {
color: #e6edf3;
font-size: 20px;
font-weight: 600;
}
</style>
@@ -0,0 +1,40 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import VolatilityChart from './VolatilityChart.vue'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
describe('VolatilityChart.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('renders chart container without crashing', () => {
const props = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
strategy: [0.12, 0.13, 0.11],
benchmark: [0.10, 0.11, 0.10],
}
const wrapper = mount(VolatilityChart, { props })
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
it('passes props correctly', () => {
const props = {
dates: ['2024-01-01', '2024-01-02'],
strategy: [0.12, 0.13],
benchmark: [0.10, 0.11],
}
const wrapper = mount(VolatilityChart, { props })
expect(wrapper.props()).toEqual(props)
})
})
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { EChartsCoreOption } from 'echarts'
import { useChart } from '@/composables/useChart'
import { darkTitle, darkTooltip, darkGrid, darkAxis } from '@/utils/echartsDark'
const STRATEGY = '#c23531' // 策略红
const BENCHMARK = '#2f4554' // 基准蓝
const props = defineProps<{ dates: string[]; strategy: number[]; benchmark: number[] }>()
const el = ref<HTMLDivElement>()
const { setOption } = useChart(el)
function render(): void {
if (!props.dates.length || !props.strategy.length || !props.benchmark.length) return
const option: EChartsCoreOption = {
title: darkTitle('波动率对比'),
tooltip: darkTooltip(),
grid: darkGrid(),
legend: {
data: ['策略波动率', '基准波动率'],
textStyle: { color: '#e6edf3', fontSize: 12 },
top: 24,
},
xAxis: {
type: 'category',
data: props.dates,
...darkAxis(),
},
yAxis: {
type: 'value',
scale: true,
name: '波动率',
...darkAxis(),
},
series: [
{
type: 'line',
name: '策略波动率',
smooth: true,
showSymbol: false,
lineStyle: { color: STRATEGY, width: 1.6 },
data: props.strategy,
},
{
type: 'line',
name: '基准波动率',
smooth: true,
showSymbol: false,
lineStyle: { color: BENCHMARK, width: 1.6 },
data: props.benchmark,
},
],
}
setOption(option)
}
onMounted(render)
watch(() => [props.dates, props.strategy, props.benchmark], render, { deep: true })
</script>
<template><div ref="el" class="chart-box" /></template>
<style scoped>.chart-box { width: 100%; height: 280px; }</style>