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
+17
View File
@@ -185,6 +185,23 @@ class TaskStateResponse(BaseModel):
elapsed: float = 0.0
class TaskSummary(BaseModel):
"""任务摘要(列表用,不含完整 result)。"""
task_id: str
status: Literal["pending", "running", "done", "failed"]
description: str = ""
created_at: float = 0.0
elapsed: float = 0.0
class TaskListResponse(BaseModel):
"""任务摘要列表响应。"""
tasks: list[TaskSummary]
count: int
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
+25
View File
@@ -22,8 +22,10 @@ from easy_tdx.web.backtest_schemas import (
OptimizeBacktestRequest,
PortfolioBacktestRequest,
StrategySchemaResponse,
TaskListResponse,
TaskStateResponse,
TaskSubmitResponse,
TaskSummary,
serialize_result,
)
from easy_tdx.web.deps import get_client
@@ -101,6 +103,29 @@ async def run_backtest_async(
return TaskSubmitResponse(task_id=task_id, status=status)
@router.get("/backtest/tasks", response_model=TaskListResponse)
async def list_tasks(limit: int = 20) -> TaskListResponse:
"""列出最近 N 个任务摘要(按最近使用倒序,不含完整 result)。
供对比页选择要对比的 task;选中后再逐个调 /tasks/{task_id} 拉详情。
"""
import time
runner = get_runner()
states = runner.list_recent(limit)
summaries = [
TaskSummary(
task_id=s.task_id,
status=s.status,
description=s.description,
created_at=s.created_at,
elapsed=(s.finished_at or time.time()) - (s.started_at or s.created_at),
)
for s in states
]
return TaskListResponse(tasks=summaries, count=len(summaries))
@router.get("/backtest/tasks/{task_id}", response_model=TaskStateResponse)
async def get_task(task_id: str) -> TaskStateResponse:
"""查询后台回测任务状态。done 时 result 字段含完整回测结果。"""
+11
View File
@@ -131,6 +131,17 @@ class BacktestTaskRunner:
with self._lock:
return self._tasks.get(task_id)
def list_recent(self, limit: int = 20) -> list[TaskState]:
"""返回最近 N 个任务(按完成/创建时间倒序,LRU 表尾=最近)。
Args:
limit: 最多返回的任务数(默认 20)。
"""
with self._lock:
# OrderedDict 尾部是最近使用的(done 时 move_to_end);倒序取
items = list(reversed(self._tasks.values()))
return items[:limit]
def status(self, task_id: str) -> TaskStatus | None:
"""取任务状态字符串,不存在返回 None。"""
state = self.peek(task_id)
+42
View File
@@ -897,3 +897,45 @@ def test_optimize_single_param_no_heatmap(client, sample_ohlcv):
time.sleep(0.05)
assert final["status"] == "done"
assert final["result"]["heatmap"] is None
# ---------------------------------------------------------------------------
# Phase 5: 任务列表端点(对比页用)
# ---------------------------------------------------------------------------
def test_list_tasks_endpoint(client, sample_ohlcv):
"""GET /backtest/tasks 返回最近任务摘要列表。"""
for _ in range(2):
client.post(
"/api/v1/backtest/run/async",
json={"strategy": "ma_cross", "ohlcv": sample_ohlcv},
)
import time as _time
_time.sleep(0.5)
resp = client.get("/api/v1/backtest/tasks?limit=20")
assert resp.status_code == 200
body = resp.json()
assert body["count"] >= 2
task = body["tasks"][0]
assert "task_id" in task
assert "status" in task
assert "description" in task
assert "result" not in task # 摘要不含完整 result
def test_list_tasks_limit(client, sample_ohlcv):
"""limit 参数应限制返回数量。"""
for _ in range(3):
client.post(
"/api/v1/backtest/run/async",
json={"strategy": "ma_cross", "ohlcv": sample_ohlcv},
)
import time as _time
_time.sleep(0.5)
resp = client.get("/api/v1/backtest/tasks?limit=2")
assert resp.json()["count"] <= 2
+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>