mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 16:54:17 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
# ── 结果序列化 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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 字段含完整回测结果。"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 回测请求
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user