feat(backtest): 回测结果对比页

选 2-4 个已完成的回测 task,叠加净值曲线 + 横向指标对比。

后端(最小增量):
- task_runner.list_recent(limit):返回最近 N 个任务摘要(LRU 倒序)
- GET /backtest/tasks?limit=20:任务摘要列表端点(不含完整 result)
- TaskSummary / TaskListResponse schema

前端(/compare 对比页):
- CompareView:左栏勾选已完成 task,右栏叠加对比
- CompareChart:多 task 净值叠加图(归一化为初始=1)
- CompareTable:多 task 指标横向对比表(8 项指标)
- 按 result 结构判断可对比性(仅单标的 BacktestResult 可对比)

复用现有 task_runner LRU 表,无新增存储。测试:823 passed(+2 列表端点)
This commit is contained in:
Justin Gu
2026-07-03 04:11:29 +08:00
parent 87fafe9131
commit ff3476f83a
11 changed files with 501 additions and 1 deletions
+1
View File
@@ -10,6 +10,7 @@
<RouterLink to="/" active-class="active">单标的回测</RouterLink>
<RouterLink to="/portfolio" active-class="active">组合回测</RouterLink>
<RouterLink to="/optimize" active-class="active">参数寻优</RouterLink>
<RouterLink to="/compare" active-class="active">结果对比</RouterLink>
</nav>
</header>
<main class="app-main">
+8
View File
@@ -9,6 +9,7 @@ import type {
Category,
OptimizeBacktestRequest,
PortfolioBacktestRequest,
TaskListResponse,
StrategiesResponse,
TaskState,
TaskSubmitResponse,
@@ -145,6 +146,13 @@ export async function fetchTask(taskId: string): Promise<TaskState> {
return (await resp.json()) as TaskState
}
/** 列出最近任务摘要(供对比页选择)。 */
export async function fetchTaskList(limit = 20): Promise<TaskListResponse> {
const resp = await fetch(`${BASE}/backtest/tasks?limit=${limit}`)
if (!resp.ok) await throwError(resp)
return (await resp.json()) as TaskListResponse
}
/**
* 提交后台任务并轮询直到 done/failed。
* @param req 回测请求
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
// 多 task 净值叠加对比图(归一化为初始=1)。
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import echarts from '../echarts-setup'
import type { BacktestResult } from '../types'
const props = defineProps<{
items: Array<{ label: string; result: BacktestResult }>
}>()
const container = ref<HTMLDivElement>()
let chart: echarts.ECharts | null = null
function render() {
if (!container.value || props.items.length === 0) return
chart ??= echarts.init(container.value, 'dark')
chart.setOption(buildOption(), true)
}
function buildOption(): echarts.EChartsCoreOption {
const seriesData = props.items.map((item) => {
const ec = item.result.equity_curve
const initial = ec[0]?.total || 1
return {
name: item.label,
dates: ec.map((e) => e.datetime.slice(0, 10)),
values: ec.map((e) => e.total / initial),
}
})
const allDates = Array.from(new Set(seriesData.flatMap((s) => s.dates))).sort()
return {
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { top: 0, data: seriesData.map((s) => s.name) },
grid: { left: '8%', right: '5%', top: 30, bottom: 50 },
xAxis: { type: 'category', data: allDates, boundaryGap: false },
yAxis: { type: 'value', scale: true, name: '归一化净值' },
dataZoom: [{ type: 'inside', start: 0, end: 100 }],
series: seriesData.map((s) => {
const valMap = new Map(s.dates.map((d, i) => [d, s.values[i]]))
let last = 1
const aligned = allDates.map((d) => {
const v = valMap.get(d)
if (v !== undefined) last = v
return last
})
return {
name: s.name,
type: 'line',
data: aligned,
symbol: 'none',
lineStyle: { width: 1.5 },
}
}),
}
}
function resize() {
chart?.resize()
}
onMounted(() => {
render()
window.addEventListener('resize', resize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', resize)
chart?.dispose()
chart = null
})
watch(() => props.items, render)
</script>
<template>
<div ref="container" class="compare-chart"></div>
</template>
<style scoped>
.compare-chart {
width: 100%;
height: 380px;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
// 多 task 指标横向对比表(每个 task 一列或一行)。
import type { BacktestResult } from '../types'
const props = defineProps<{
items: Array<{ label: string; result: BacktestResult }>
}>()
interface Row {
label: string
key: keyof BacktestResult['performance']
format: 'percent' | 'ratio' | 'int'
}
const ROWS: Row[] = [
{ label: '总收益', key: 'total_return', format: 'percent' },
{ label: '年化收益', key: 'annual_return', format: 'percent' },
{ label: '夏普', key: 'sharpe', format: 'ratio' },
{ label: '最大回撤', key: 'max_drawdown', format: 'percent' },
{ label: '胜率', key: 'win_rate', format: 'percent' },
{ label: '盈亏比', key: 'profit_factor', format: 'ratio' },
{ label: '交易数', key: 'total_trades', format: 'int' },
{ label: '波动率', key: 'volatility', format: 'percent' },
]
function fmt(row: Row, v: number | undefined): string {
if (v === undefined || !Number.isFinite(v)) return '-'
if (row.format === 'percent') return `${(v * 100).toFixed(2)}%`
if (row.format === 'int') return String(Math.round(v))
return v.toFixed(3)
}
</script>
<template>
<table class="compare-table">
<thead>
<tr>
<th>指标</th>
<th v-for="item in props.items" :key="item.label" class="col">{{ item.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in ROWS" :key="row.key">
<td class="metric-label">{{ row.label }}</td>
<td v-for="item in props.items" :key="item.label" class="num">
{{ fmt(row, item.result.performance[row.key] as number) }}
</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.compare-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.compare-table th,
.compare-table td {
padding: 7px 12px;
border-bottom: 1px solid var(--border);
text-align: left;
}
.compare-table th {
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.compare-table th.col {
color: var(--accent);
font-family: var(--font-mono);
}
.metric-label {
color: var(--text-muted);
white-space: nowrap;
}
.num {
font-family: var(--font-mono);
text-align: right;
}
</style>
+3 -1
View File
@@ -1,14 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import BacktestView from './views/BacktestView.vue'
import CompareView from './views/CompareView.vue'
import OptimizeView from './views/OptimizeView.vue'
import PortfolioView from './views/PortfolioView.vue'
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize)。
// 单标的回测(/+ 组合回测(/portfolio+ 参数寻优(/optimize+ 结果对比(/compare
const routes = [
{ path: '/', name: 'backtest', component: BacktestView },
{ path: '/portfolio', name: 'portfolio', component: PortfolioView },
{ path: '/optimize', name: 'optimize', component: OptimizeView },
{ path: '/compare', name: 'compare', component: CompareView },
]
export const router = createRouter({
+15
View File
@@ -136,6 +136,21 @@ export interface TaskState {
elapsed: number
}
// ── 任务摘要(Phase 5 对比页) ────────────────────────────────────────────────
export interface TaskSummary {
task_id: string
status: TaskStatus
description: string
created_at: number
elapsed: number
}
export interface TaskListResponse {
tasks: TaskSummary[]
count: number
}
// ── 组合回测(Phase 3) ───────────────────────────────────────────────────────
export interface PortfolioBacktestRequest {
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
// 结果对比页面:选 2-4 个已完成的回测 task,叠加净值曲线 + 横向指标对比。
import { onMounted, ref, watch } from 'vue'
import CompareChart from '../components/CompareChart.vue'
import CompareTable from '../components/CompareTable.vue'
import { fetchTask, fetchTaskList, formatError } from '../api'
import type { BacktestResult, TaskSummary } from '../types'
const taskList = ref<TaskSummary[]>([])
const loading = ref(false)
const error = ref('')
const selectedIds = ref<Set<string>>(new Set())
// 已加载的详情(task_id → BacktestResult
const details = ref<Map<string, { label: string; result: BacktestResult }>>(new Map())
onMounted(loadTasks)
async function loadTasks() {
loading.value = true
error.value = ''
try {
const resp = await fetchTaskList(20)
// 只显示已完成的单标的回测(status=done 且 result 是 BacktestResult
taskList.value = resp.tasks.filter((t) => t.status === 'done')
} catch (e) {
error.value = formatError(e)
} finally {
loading.value = false
}
}
async function toggle(taskId: string) {
if (selectedIds.value.has(taskId)) {
selectedIds.value.delete(taskId)
details.value.delete(taskId)
} else {
if (selectedIds.value.size >= 4) return // 最多 4 个
selectedIds.value.add(taskId)
// 拉详情
try {
const state = await fetchTask(taskId)
const result = state.result as BacktestResult
if (!result?.performance || !result?.equity_curve) {
throw new Error('该任务结果不可对比(非单标的回测)')
}
details.value.set(taskId, { label: state.description, result })
} catch (e) {
selectedIds.value.delete(taskId)
error.value = e instanceof Error ? e.message : String(e)
}
}
// 触发响应式
selectedIds.value = new Set(selectedIds.value)
details.value = new Map(details.value)
}
const compareItems = ref<Array<{ label: string; result: BacktestResult }>>([])
function refreshItems() {
compareItems.value = Array.from(details.value.values())
}
// 监听 details 变化刷新对比项
watch(details, refreshItems, { deep: true })
</script>
<template>
<div class="compare-view">
<aside class="config-panel">
<section class="panel-section">
<h3>选择对比任务</h3>
<button class="refresh-btn" :disabled="loading" @click="loadTasks">
{{ loading ? '加载中' : '刷新列表' }}
</button>
<p class="hint">勾选 2-4 个已完成的回测任务进行对比</p>
<div v-if="taskList.length === 0" class="empty">暂无已完成的任务</div>
<div v-for="t in taskList" :key="t.task_id" class="task-item">
<label class="check">
<input
type="checkbox"
:checked="selectedIds.has(t.task_id)"
:disabled="!selectedIds.has(t.task_id) && selectedIds.size >= 4"
@change="toggle(t.task_id)"
/>
<span class="task-desc">{{ t.description || t.task_id.slice(0, 8) }}</span>
</label>
<span class="task-meta">{{ t.elapsed.toFixed(1) }}s</span>
</div>
</section>
</aside>
<main class="report-panel">
<div v-if="error" class="error-banner"> {{ error }}</div>
<div v-if="compareItems.length < 2" class="placeholder">
<p>勾选至少 2 个任务进行对比</p>
</div>
<div v-else class="report-content">
<section class="report-section">
<h3>净值曲线对比归一化</h3>
<CompareChart :items="compareItems" />
</section>
<section class="report-section">
<h3>指标对比</h3>
<CompareTable :items="compareItems" />
</section>
</div>
</main>
</div>
</template>
<style scoped>
.compare-view {
display: flex;
height: 100%;
}
.config-panel {
width: 320px;
flex-shrink: 0;
background: var(--bg-panel);
border-right: 1px solid var(--border);
padding: 16px;
overflow-y: auto;
}
.panel-section h3 {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.refresh-btn {
width: 100%;
margin-bottom: 12px;
}
.hint {
color: var(--text-muted);
font-size: 12px;
margin-bottom: 10px;
}
.empty {
color: var(--text-dim);
font-size: 13px;
padding: 16px 0;
}
.task-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.check {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.check input[type='checkbox'] {
width: auto;
}
.task-desc {
color: var(--text);
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.task-meta {
color: var(--text-dim);
font-size: 11px;
flex-shrink: 0;
}
.report-panel {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-dim);
}
.error-banner {
background: rgba(239, 65, 70, 0.12);
border: 1px solid var(--up);
color: var(--up);
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 16px;
font-size: 13px;
}
.report-section {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 16px;
}
.report-section h3 {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
}
</style>