fix(backtest): 结果页指标全显"—" + 任务重启后404

两个根因:

1. relative_metrics 缺字段(routes.py): get_result 的 relative_fields 列表
   漏了 total_return/annual_return/sharpe_ratio/max_drawdown 4 个字段, 导致
   这 4 个指标永远进不了响应 → 前端 MetricCards 显"—"。补全为 11 字段。

2. 任务 task_id 不持久(runner/cta_engine): submit_cta 用 cta_{symbol}_{id(params)}
   (内存地址) 作 runner id, 而 run_cta_backtest 内部另生成 cta_{uuid} 存 DB,
   两者持久层不相交 → 重启后 pool 内存映射丢失, get_result(runner_id) 的
   load_result_by_task_id 查不到 → /result 404 → 前端指标全 0 + 图表无数据。
   改为 submit 前置生成稳定 uuid, 透传给 run_cta_backtest 复用, 使
   runner-id == DB task_id (单一 id, 重启可查)。

附: Dashboard 最近任务"策略/因子"列 min-width 150→190(长策略名不再截断)
测试: test_runner task_id 断言同步新格式
This commit is contained in:
2026-07-11 22:10:48 +08:00
parent 41a788c431
commit 59da65b839
5 changed files with 21 additions and 11 deletions
+1 -1
View File
@@ -108,7 +108,7 @@ function statusLabel(s: string): string {
<el-table-column label="类型" width="84"> <el-table-column label="类型" width="84">
<template #default="{ row }"><span class="chip" :class="`chip-${row.type}`">{{ typeLabel(row.type) }}</span></template> <template #default="{ row }"><span class="chip" :class="`chip-${row.type}`">{{ typeLabel(row.type) }}</span></template>
</el-table-column> </el-table-column>
<el-table-column prop="strategy" label="策略 / 因子" min-width="150" show-overflow-tooltip /> <el-table-column prop="strategy" label="策略 / 因子" min-width="190" show-overflow-tooltip />
<el-table-column prop="symbol" label="标的" width="90" /> <el-table-column prop="symbol" label="标的" width="90" />
<el-table-column label="状态" width="84"> <el-table-column label="状态" width="84">
<template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel(row.status) }}</span></template> <template #default="{ row }"><span class="chip" :class="`st-${row.status}`">{{ statusLabel(row.status) }}</span></template>
+2 -1
View File
@@ -139,8 +139,9 @@ def get_result(task_id: str):
# Extract relative metrics from statistics # Extract relative metrics from statistics
relative_metrics = {} relative_metrics = {}
relative_fields = [ relative_fields = [
"total_return", "annual_return", "sharpe_ratio", "max_drawdown",
"alpha", "beta", "sortino_ratio", "information_ratio", "alpha", "beta", "sortino_ratio", "information_ratio",
"annual_volatility", "benchmark_return", "benchmark_volatility" "annual_volatility", "benchmark_return", "benchmark_volatility",
] ]
for field in relative_fields: for field in relative_fields:
if field in r.statistics: if field in r.statistics:
+6 -2
View File
@@ -48,7 +48,7 @@ def guess_exchange(symbol: str) -> Exchange:
return Exchange("SSE") return Exchange("SSE")
def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, db_path: str, benchmark: str = "hs300") -> BacktestResult: def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, db_path: str, benchmark: str = "hs300", task_id: str | None = None) -> BacktestResult:
""" """
Run CTA strategy backtest using vnpy_ctastrategy BacktestingEngine. Run CTA strategy backtest using vnpy_ctastrategy BacktestingEngine.
@@ -60,11 +60,15 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
end: Backtest end date (YYYY-MM-DD format) end: Backtest end date (YYYY-MM-DD format)
cfg: Configuration object (may contain data paths) cfg: Configuration object (may contain data paths)
db_path: SQLite database path for saving results db_path: SQLite database path for saving results
benchmark: Benchmark code (hs300/zz500)
task_id: Optional task ID from runner (reused as the persisted task_id so
runner-id == DB task_id; if omitted a fresh uuid is generated).
Returns: Returns:
BacktestResult: Result object with backtest statistics and status BacktestResult: Result object with backtest statistics and status
""" """
# Generate unique task ID # Use runner-provided task_id (durable, single id across pool/DB/URL) or generate
if not task_id:
task_id = f"cta_{uuid.uuid4().hex[:8]}" task_id = f"cta_{uuid.uuid4().hex[:8]}"
try: try:
+7 -4
View File
@@ -3,6 +3,7 @@ Orchestrator for task coordination and execution
Manages backtesting tasks with lazy imports Manages backtesting tasks with lazy imports
""" """
import asyncio import asyncio
import uuid
from concurrent.futures import Future from concurrent.futures import Future
from .pool import TaskPool from .pool import TaskPool
from .task import TaskState from .task import TaskState
@@ -32,7 +33,9 @@ class Orchestrator:
async def submit_cta(self, strategy_class, symbol: str, params: dict, async def submit_cta(self, strategy_class, symbol: str, params: dict,
start: str, end: str, cfg, benchmark: str = "hs300") -> str: start: str, end: str, cfg, benchmark: str = "hs300") -> str:
"""Submit a CTA backtesting task asynchronously""" """Submit a CTA backtesting task asynchronously"""
task_id = f"cta_{symbol}_{id(params)}" # Stable uuid up front → reused as the persisted DB task_id, so runner-id ==
# DB task_id (durable across restarts; previously used id(params) memory addr).
task_id = f"cta_{uuid.uuid4().hex[:8]}"
self.pool.submit(task_id, "cta") self.pool.submit(task_id, "cta")
self._pending[task_id] = dict( self._pending[task_id] = dict(
strategy_class=strategy_class, strategy_class=strategy_class,
@@ -48,7 +51,7 @@ class Orchestrator:
spec = self._pending[task_id] spec = self._pending[task_id]
fut: Future = self.pool.submit_work( fut: Future = self.pool.submit_work(
task_id, _cta_worker, spec["strategy_class"], spec["symbol"], task_id, _cta_worker, spec["strategy_class"], spec["symbol"],
spec["params"], spec["start"], spec["end"], spec["cfg"], spec["benchmark"], self.db_path spec["params"], spec["start"], spec["end"], spec["cfg"], spec["benchmark"], self.db_path, task_id
) )
task = self.pool.get_task(task_id) task = self.pool.get_task(task_id)
@@ -165,10 +168,10 @@ class Orchestrator:
# Module-level worker functions (must be top-level for ProcessPoolExecutor pickle) # Module-level worker functions (must be top-level for ProcessPoolExecutor pickle)
def _cta_worker(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, benchmark: str, db_path: str) -> any: def _cta_worker(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, benchmark: str, db_path: str, task_id: str) -> any:
"""Worker for CTA backtest (lazy import, spawn-friendly)""" """Worker for CTA backtest (lazy import, spawn-friendly)"""
from sanguo_backtest.cta_engine import run_cta_backtest from sanguo_backtest.cta_engine import run_cta_backtest
return run_cta_backtest(strategy_class, symbol, params, start, end, cfg, db_path, benchmark=benchmark) return run_cta_backtest(strategy_class, symbol, params, start, end, cfg, db_path, benchmark=benchmark, task_id=task_id)
def _opt_worker(strategy_class, symbol: str, grid: dict, start: str, end: str, cfg, db_path: str) -> any: def _opt_worker(strategy_class, symbol: str, grid: dict, start: str, end: str, cfg, db_path: str) -> any:
+4 -2
View File
@@ -52,7 +52,9 @@ class TestOrchestrator:
assert orchestrator._pending[task_id]["end"] == end assert orchestrator._pending[task_id]["end"] == end
assert orchestrator._pending[task_id]["cfg"] == cfg assert orchestrator._pending[task_id]["cfg"] == cfg
assert task_id.startswith("cta_AAPL_") # Durable uuid format: cta_<8hex> (reused as persisted DB task_id)
assert task_id.startswith("cta_")
assert task_id != "cta_AAPL_" and len(task_id) == len("cta_") + 8
@patch('sanguo_orchestrator.runner.TaskPool') @patch('sanguo_orchestrator.runner.TaskPool')
def test_get_status(self, mock_pool_class): def test_get_status(self, mock_pool_class):
@@ -144,7 +146,7 @@ class TestOrchestratorAsync:
Mock(), "600000", {}, "2024-01-01", "2024-06-30", cfg=Mock() Mock(), "600000", {}, "2024-01-01", "2024-06-30", cfg=Mock()
) )
assert task_id.startswith("cta_600000") assert task_id.startswith("cta_") and len(task_id) == len("cta_") + 8
orchestrator.pool.executor.submit.assert_called_once() orchestrator.pool.executor.submit.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio