feat(frontend): Result.vue重构—聚宽级10指标+5图+4tab+时间缩放
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
<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>
|
||||
</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>
|
||||
<!-- Top: Metric Cards -->
|
||||
<MetricCards :metrics="relativeMetrics" />
|
||||
|
||||
<el-card>
|
||||
<KlineChart :kline="kline" :trades="trades" />
|
||||
</el-card>
|
||||
<!-- 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>
|
||||
|
||||
<el-card>
|
||||
<template #header><span class="section-title">成交记录</span></template>
|
||||
<TradesTable :trades="trades" />
|
||||
</el-card>
|
||||
<!-- Tabs Layout -->
|
||||
<el-tabs type="border-card">
|
||||
<!-- Tab 1: 收益概述 -->
|
||||
<el-tab-pane label="收益概述">
|
||||
<div class="charts-container">
|
||||
<el-card>
|
||||
<BenchmarkCurve
|
||||
:dates="filteredBenchmarkCurve.dates"
|
||||
:strategy="filteredBenchmarkCurve.strategy"
|
||||
:benchmark="filteredBenchmarkCurve.benchmark"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user