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)