diff --git a/src/easy_tdx/web/backtest_schemas.py b/src/easy_tdx/web/backtest_schemas.py index b1057dc..3e26767 100644 --- a/src/easy_tdx/web/backtest_schemas.py +++ b/src/easy_tdx/web/backtest_schemas.py @@ -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 + + # ── 结果序列化 ───────────────────────────────────────────────────────────────── diff --git a/src/easy_tdx/web/routers/backtest.py b/src/easy_tdx/web/routers/backtest.py index 3c5515a..be25772 100644 --- a/src/easy_tdx/web/routers/backtest.py +++ b/src/easy_tdx/web/routers/backtest.py @@ -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 字段含完整回测结果。""" diff --git a/src/easy_tdx/web/task_runner.py b/src/easy_tdx/web/task_runner.py index c752825..22e9459 100644 --- a/src/easy_tdx/web/task_runner.py +++ b/src/easy_tdx/web/task_runner.py @@ -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) diff --git a/tests/unit/test_web_backtest.py b/tests/unit/test_web_backtest.py index cb97709..f96c4a1 100644 --- a/tests/unit/test_web_backtest.py +++ b/tests/unit/test_web_backtest.py @@ -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 diff --git a/web-ui/src/App.vue b/web-ui/src/App.vue index b1723ad..48ae2d2 100644 --- a/web-ui/src/App.vue +++ b/web-ui/src/App.vue @@ -10,6 +10,7 @@ 单标的回测 组合回测 参数寻优 + 结果对比
diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index a694dba..a61b4e6 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -9,6 +9,7 @@ import type { Category, OptimizeBacktestRequest, PortfolioBacktestRequest, + TaskListResponse, StrategiesResponse, TaskState, TaskSubmitResponse, @@ -145,6 +146,13 @@ export async function fetchTask(taskId: string): Promise { return (await resp.json()) as TaskState } +/** 列出最近任务摘要(供对比页选择)。 */ +export async function fetchTaskList(limit = 20): Promise { + 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 回测请求 diff --git a/web-ui/src/components/CompareChart.vue b/web-ui/src/components/CompareChart.vue new file mode 100644 index 0000000..254e66f --- /dev/null +++ b/web-ui/src/components/CompareChart.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/web-ui/src/components/CompareTable.vue b/web-ui/src/components/CompareTable.vue new file mode 100644 index 0000000..d46a888 --- /dev/null +++ b/web-ui/src/components/CompareTable.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/web-ui/src/router.ts b/web-ui/src/router.ts index 4040e04..716b0e8 100644 --- a/web-ui/src/router.ts +++ b/web-ui/src/router.ts @@ -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({ diff --git a/web-ui/src/types.ts b/web-ui/src/types.ts index 8822ef9..8399baa 100644 --- a/web-ui/src/types.ts +++ b/web-ui/src/types.ts @@ -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 { diff --git a/web-ui/src/views/CompareView.vue b/web-ui/src/views/CompareView.vue new file mode 100644 index 0000000..7247e42 --- /dev/null +++ b/web-ui/src/views/CompareView.vue @@ -0,0 +1,210 @@ + + + + +