diff --git a/frontend/src/views/backtest/Result.spec.ts b/frontend/src/views/backtest/Result.spec.ts new file mode 100644 index 0000000..85a90d5 --- /dev/null +++ b/frontend/src/views/backtest/Result.spec.ts @@ -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) + }) +}) diff --git a/frontend/src/views/backtest/Result.vue b/frontend/src/views/backtest/Result.vue index 80b14a5..2e886c2 100644 --- a/frontend/src/views/backtest/Result.vue +++ b/frontend/src/views/backtest/Result.vue @@ -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({ + 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({ dates: [], strategy: [], benchmark: [] }) +const riskSeries = ref({ dates: [], alpha: [], beta: [], drawdown: [] }) + +// Existing data const statistics = ref>({}) const equity = ref([]) const pnl = ref([]) const trades = ref([]) const kline = ref([]) -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('all') + +function filterDataByTimeRange(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 () => {

回测结果 {{ taskId }}

-

统计指标 / 资金曲线 / K 线买卖点 / 成交明细

+

统计指标 / 基准对比 / 风险分析 / 交易明细

- - - - - {{ e.value }} - - - - - - - + + - - - + +
+ 时间范围: + + 1周 + 1月 + 6月 + 1年 + 全部 + +
- - - - + + + + +
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + +
+ + + + +
+
+
+ + + + + +
暂无日志数据
+
+
+
+ +