feat(frontend): Result.vue重构—聚宽级10指标+5图+4tab+时间缩放

This commit is contained in:
2026-07-11 13:57:35 +08:00
parent d0315ccbdf
commit c81d254196
2 changed files with 483 additions and 34 deletions
+223
View File
@@ -0,0 +1,223 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import Result from './Result.vue'
import * as backtestApi from '@/api/backtest'
// Mock echarts to avoid canvas issues in jsdom
vi.mock('echarts', () => ({
init: vi.fn(() => ({
setOption: vi.fn(),
dispose: vi.fn(),
resize: vi.fn(),
})),
}))
// Mock all API calls
vi.mock('@/api/backtest', () => ({
getResult: vi.fn(),
getEquityCurve: vi.fn(),
getDailyPnl: vi.fn(),
getTrades: vi.fn(),
getKline: vi.fn(),
getRelativeMetrics: vi.fn(),
getBenchmarkCurve: vi.fn(),
getRiskSeries: vi.fn(),
}))
describe('Result.vue', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/backtest/result/:id', component: Result }],
})
const mockRelativeMetrics = {
total_return: 0.15,
annual_return: 0.12,
alpha: 0.03,
beta: 1.2,
sharpe_ratio: 1.5,
sortino_ratio: 2.1,
information_ratio: 0.8,
annual_volatility: 0.18,
max_drawdown: -0.08,
benchmark_return: 0.1,
benchmark_volatility: 0.15,
}
const mockBenchmarkCurve = {
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 mockRiskSeries = {
dates: ['2024-01-01', '2024-01-02', '2024-01-03'],
alpha: [0.01, 0.02, 0.03],
beta: [1.1, 1.2, 1.15],
drawdown: [0.0, -0.02, -0.05],
}
const mockTrades = [
{
datetime: '2024-01-01 09:30:00',
direction: 'BUY',
offset: 'OPEN',
price: 100.5,
volume: 1000,
vt_symbol: '600000.SH',
},
]
it('renders metric cards when data loaded', async () => {
vi.mocked(backtestApi.getResult).mockResolvedValue({
task_id: 'test-task-1',
statistics: {},
symbol: '600000.SH',
start: '2024-01-01',
end: '2024-01-03',
strategy: 'test_strategy',
params: {},
status: 'completed',
})
vi.mocked(backtestApi.getRelativeMetrics).mockResolvedValue(mockRelativeMetrics)
vi.mocked(backtestApi.getBenchmarkCurve).mockResolvedValue(mockBenchmarkCurve)
vi.mocked(backtestApi.getRiskSeries).mockResolvedValue(mockRiskSeries)
vi.mocked(backtestApi.getTrades).mockResolvedValue(mockTrades)
vi.mocked(backtestApi.getEquityCurve).mockResolvedValue([])
vi.mocked(backtestApi.getDailyPnl).mockResolvedValue([])
vi.mocked(backtestApi.getKline).mockResolvedValue([])
await router.push('/backtest/result/test-task-1')
await router.isReady()
const wrapper = mount(Result, {
global: {
plugins: [router],
},
})
// Wait for async data loading
await new Promise(resolve => setTimeout(resolve, 100))
// Check if metric cards section exists
expect(wrapper.find('.metric-cards').exists()).toBe(true)
})
it('renders time range selector', async () => {
vi.mocked(backtestApi.getResult).mockResolvedValue({
task_id: 'test-task-2',
statistics: {},
symbol: '600000.SH',
start: '2024-01-01',
end: '2024-01-03',
strategy: 'test_strategy',
params: {},
status: 'completed',
})
vi.mocked(backtestApi.getRelativeMetrics).mockResolvedValue(mockRelativeMetrics)
vi.mocked(backtestApi.getBenchmarkCurve).mockResolvedValue(mockBenchmarkCurve)
vi.mocked(backtestApi.getRiskSeries).mockResolvedValue(mockRiskSeries)
vi.mocked(backtestApi.getTrades).mockResolvedValue(mockTrades)
vi.mocked(backtestApi.getEquityCurve).mockResolvedValue([])
vi.mocked(backtestApi.getDailyPnl).mockResolvedValue([])
vi.mocked(backtestApi.getKline).mockResolvedValue([])
await router.push('/backtest/result/test-task-2')
await router.isReady()
const wrapper = mount(Result, {
global: {
plugins: [router],
},
})
// Wait for async data loading
await new Promise(resolve => setTimeout(resolve, 100))
// Check if time range selector exists
expect(wrapper.find('.time-range-selector').exists()).toBe(true)
})
it('renders four tabs', async () => {
vi.mocked(backtestApi.getResult).mockResolvedValue({
task_id: 'test-task-3',
statistics: {},
symbol: '600000.SH',
start: '2024-01-01',
end: '2024-01-03',
strategy: 'test_strategy',
params: {},
status: 'completed',
})
vi.mocked(backtestApi.getRelativeMetrics).mockResolvedValue(mockRelativeMetrics)
vi.mocked(backtestApi.getBenchmarkCurve).mockResolvedValue(mockBenchmarkCurve)
vi.mocked(backtestApi.getRiskSeries).mockResolvedValue(mockRiskSeries)
vi.mocked(backtestApi.getTrades).mockResolvedValue(mockTrades)
vi.mocked(backtestApi.getEquityCurve).mockResolvedValue([])
vi.mocked(backtestApi.getDailyPnl).mockResolvedValue([])
vi.mocked(backtestApi.getKline).mockResolvedValue([])
await router.push('/backtest/result/test-task-3')
await router.isReady()
const wrapper = mount(Result, {
global: {
plugins: [router],
},
})
// Wait for async data loading
await new Promise(resolve => setTimeout(resolve, 100))
// Check if the component contains tab labels by looking at text content
const html = wrapper.html()
expect(html).toContain('收益概述')
expect(html).toContain('交易详情')
expect(html).toContain('每日持仓&收益')
expect(html).toContain('日志输出')
})
it('renders chart containers', async () => {
vi.mocked(backtestApi.getResult).mockResolvedValue({
task_id: 'test-task-4',
statistics: {},
symbol: '600000.SH',
start: '2024-01-01',
end: '2024-01-03',
strategy: 'test_strategy',
params: {},
status: 'completed',
})
vi.mocked(backtestApi.getRelativeMetrics).mockResolvedValue(mockRelativeMetrics)
vi.mocked(backtestApi.getBenchmarkCurve).mockResolvedValue(mockBenchmarkCurve)
vi.mocked(backtestApi.getRiskSeries).mockResolvedValue(mockRiskSeries)
vi.mocked(backtestApi.getTrades).mockResolvedValue(mockTrades)
vi.mocked(backtestApi.getEquityCurve).mockResolvedValue([])
vi.mocked(backtestApi.getDailyPnl).mockResolvedValue([])
vi.mocked(backtestApi.getKline).mockResolvedValue([])
await router.push('/backtest/result/test-task-4')
await router.isReady()
const wrapper = mount(Result, {
global: {
plugins: [router],
},
})
// Wait for async data loading
await new Promise(resolve => setTimeout(resolve, 100))
// Check if chart containers exist
expect(wrapper.find('.chart-box').exists()).toBe(true)
})
})
+254 -28
View File
@@ -3,40 +3,130 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {
getResult, getEquityCurve, getDailyPnl, getTrades, getKline,
getRelativeMetrics, getBenchmarkCurve, getRiskSeries,
type EquityPoint, type PnlPoint, type Trade, type KlineBar,
type RelativeMetrics, type BenchmarkCurveData, type RiskSeriesData,
} from '@/api/backtest'
import EquityChart from '@/components/charts/EquityChart.vue'
import DailyPnlChart from '@/components/charts/DailyPnlChart.vue'
import KlineChart from '@/components/charts/KlineChart.vue'
import TradesTable from '@/components/TradesTable.vue'
import MetricCards from '@/components/backtest/MetricCards.vue'
import BenchmarkCurve from '@/components/backtest/BenchmarkCurve.vue'
import AlphaChart from '@/components/backtest/AlphaChart.vue'
import BetaChart from '@/components/backtest/BetaChart.vue'
import DrawdownChart from '@/components/backtest/DrawdownChart.vue'
const route = useRoute()
const taskId = String(route.params.id)
const loading = ref(true)
// New data for enhanced result page
const relativeMetrics = ref<RelativeMetrics>({
total_return: 0,
annual_return: 0,
alpha: 0,
beta: 0,
sharpe_ratio: 0,
sortino_ratio: 0,
information_ratio: 0,
annual_volatility: 0,
max_drawdown: 0,
benchmark_return: 0,
benchmark_volatility: 0,
})
const benchmarkCurve = ref<BenchmarkCurveData>({ dates: [], strategy: [], benchmark: [] })
const riskSeries = ref<RiskSeriesData>({ dates: [], alpha: [], beta: [], drawdown: [] })
// Existing data
const statistics = ref<Record<string, unknown>>({})
const equity = ref<EquityPoint[]>([])
const pnl = ref<PnlPoint[]>([])
const trades = ref<Trade[]>([])
const kline = ref<KlineBar[]>([])
const statEntries = computed(() =>
Object.entries(statistics.value)
.map(([k, v]) => ({
key: k,
value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
}))
)
// statEntries is no longer used in the new layout but kept for potential future use
// const statEntries = computed(() =>
// Object.entries(statistics.value)
// .map(([k, v]) => ({
// key: k,
// value: typeof v === 'number' ? Math.round(v * 10000) / 10000 : v,
// }))
// )
// Time range filtering
type TimeRange = '1w' | '1m' | '6m' | '1y' | 'all'
const selectedTimeRange = ref<TimeRange>('all')
function filterDataByTimeRange<T extends { dates: string[] }>(data: T): T {
if (selectedTimeRange.value === 'all') return data
const dates = data.dates
if (!dates.length) return data
const now = new Date(dates[dates.length - 1])
let cutoffDate: Date
switch (selectedTimeRange.value) {
case '1w':
cutoffDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case '1m':
cutoffDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
break
case '6m':
cutoffDate = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000)
break
case '1y':
cutoffDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
break
default:
return data
}
const startIndex = dates.findIndex(date => new Date(date) >= cutoffDate)
if (startIndex === -1) {
const emptyData: any = { dates: [] }
Object.keys(data).forEach(key => {
if (key !== 'dates') {
emptyData[key] = []
}
})
return emptyData as T
}
const filteredData: any = { dates: dates.slice(startIndex) }
Object.entries(data).forEach(([key, value]) => {
if (key !== 'dates' && Array.isArray(value)) {
filteredData[key] = value.slice(startIndex)
} else {
filteredData[key] = value
}
})
return filteredData as T
}
const filteredBenchmarkCurve = computed(() => filterDataByTimeRange(benchmarkCurve.value))
const filteredRiskSeries = computed(() => filterDataByTimeRange(riskSeries.value))
onMounted(async () => {
try {
const info = await getResult(taskId)
statistics.value = info.statistics || {}
const [eq, p, tr] = await Promise.all([
getEquityCurve(taskId), getDailyPnl(taskId), getTrades(taskId),
const [info, relMetrics, benchCurve, riskSer, eq, p, tr] = await Promise.all([
getResult(taskId),
getRelativeMetrics(taskId),
getBenchmarkCurve(taskId),
getRiskSeries(taskId),
getEquityCurve(taskId),
getDailyPnl(taskId),
getTrades(taskId),
])
statistics.value = info.statistics || {}
relativeMetrics.value = relMetrics
benchmarkCurve.value = benchCurve
riskSeries.value = riskSer
equity.value = eq
pnl.value = p
trades.value = tr
if (info.symbol && info.start && info.end) {
try {
kline.value = await getKline(info.symbol, info.start, info.end)
@@ -55,30 +145,166 @@ onMounted(async () => {
<div class="page-head">
<div>
<h2 class="page-title">回测结果 <span class="muted">{{ taskId }}</span></h2>
<p class="page-subtitle">统计指标 / 资金曲线 / K 线买卖点 / 交明细</p>
<p class="page-subtitle">统计指标 / 基准对比 / 风险分析 / 明细</p>
</div>
</div>
<!-- Top: Metric Cards -->
<MetricCards :metrics="relativeMetrics" />
<!-- Time Range Selector -->
<div class="time-range-selector">
<span class="selector-label">时间范围</span>
<el-radio-group v-model="selectedTimeRange" size="small">
<el-radio-button value="1w">1</el-radio-button>
<el-radio-button value="1m">1</el-radio-button>
<el-radio-button value="6m">6</el-radio-button>
<el-radio-button value="1y">1</el-radio-button>
<el-radio-button value="all">全部</el-radio-button>
</el-radio-group>
</div>
<!-- Tabs Layout -->
<el-tabs type="border-card">
<!-- Tab 1: 收益概述 -->
<el-tab-pane label="收益概述">
<div class="charts-container">
<el-card>
<template #header><span class="section-title">统计指标</span></template>
<el-descriptions :column="4" border>
<el-descriptions-item v-for="e in statEntries" :key="e.key" :label="e.key">
{{ e.value }}
</el-descriptions-item>
</el-descriptions>
<BenchmarkCurve
:dates="filteredBenchmarkCurve.dates"
:strategy="filteredBenchmarkCurve.strategy"
:benchmark="filteredBenchmarkCurve.benchmark"
/>
</el-card>
<el-row :gutter="16">
<el-col :span="12"><el-card><EquityChart :data="equity" /></el-card></el-col>
<el-col :span="12"><el-card><DailyPnlChart :data="pnl" /></el-card></el-col>
</el-row>
<el-card>
<KlineChart :kline="kline" :trades="trades" />
<AlphaChart
:dates="filteredRiskSeries.dates"
:alpha="filteredRiskSeries.alpha"
/>
</el-card>
<el-card>
<BetaChart
:dates="filteredRiskSeries.dates"
:beta="filteredRiskSeries.beta"
/>
</el-card>
<el-card>
<DrawdownChart
:dates="filteredRiskSeries.dates"
:drawdown="filteredRiskSeries.drawdown"
/>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 交易详情 -->
<el-tab-pane label="交易详情">
<el-card>
<template #header><span class="section-title">成交记录</span></template>
<TradesTable :trades="trades" />
</el-card>
</el-tab-pane>
<!-- Tab 3: 每日持仓&收益 -->
<el-tab-pane label="每日持仓&收益">
<el-card>
<template #header><span class="section-title">每日收益</span></template>
<div class="daily-pnl-section">
<el-table :data="pnl" stripe size="small" empty-text="无数据">
<el-table-column prop="date" label="日期" width="120" />
<el-table-column prop="pnl" label="收益" width="120" />
</el-table>
</div>
</el-card>
</el-tab-pane>
<!-- Tab 4: 日志输出 -->
<el-tab-pane label="日志输出">
<el-card>
<template #header><span class="section-title">系统日志</span></template>
<pre class="log-output">暂无日志数据</pre>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-head {
margin-bottom: 24px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #e6edf3;
margin: 0 0 8px 0;
}
.muted {
color: #8b949e;
font-size: 16px;
font-weight: 400;
}
.page-subtitle {
color: #8b949e;
font-size: 14px;
margin: 0;
}
.time-range-selector {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
padding: 12px 16px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.selector-label {
color: #8b949e;
font-size: 14px;
font-weight: 500;
}
.charts-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.charts-container .el-card {
margin-bottom: 0;
}
.daily-pnl-section {
padding: 8px 0;
}
.log-output {
background: #0d1117;
color: #8b949e;
padding: 16px;
border-radius: 6px;
font-size: 12px;
line-height: 1.6;
max-height: 400px;
overflow-y: auto;
margin: 0;
}
.section-title {
color: #e6edf3;
font-size: 14px;
font-weight: 600;
}
</style>