feat(backtest): 强制重跑 — force 绕过同参数任务结果缓存; 修 cancel key 对齐

策略回测按全参数 md5 作 job_key, 已完成任务结果保留 5 分钟 — 同参数再次
运行直接命中缓存返回旧结果 (重连/切页恢复设计的副产物), 用户改完数据后
重跑拿到的仍是旧行为产物, 此前没有绕过手段。

- startBacktest/strategy_stream 新增 force: 掺入 job_key 使其不命中缓存
  必然重算; 不动任务复用/并发语义, 重连与取消按同一 qs 重算 key 天然对齐
- 运行回测旁新增「强制重跑」按钮 (RefreshCw, 次要样式, 悬停说明语义),
  disabled 条件与主按钮一致
- 补算环境并继续改为 handleRun(true): 已失败任务的 error 同样被任务缓存
  保留, 补算成功后自动重跑不带 force 会命中同参数旧 job 直接重放补算前的
  报错 — 用户实测补算完成后仍提示"缺少前一交易日环境 2019-10-22 等 112 天"
  即此因 (实测数据已补齐: 2019-10-22~2026-10-29 共 1703 行无缺口)
- 修预存 bug: /strategy/cancel 重算 job_key 漏了 minute_fill 与
  regime_filter — 开了分钟K精确回测或市场环境过滤的任务停止按钮失效

验证: test_backtest_stream_force 3 passed (force 改 key 且幂等; cancel 带
force/minute_fill/regime_filter 的 qs 能取消到、缺参取消不到 — 走真实
qs→key 解析路径不启动回测线程); ruff 与 HEAD 对照零新增; pnpm build 通过。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 13:09:50 +08:00
parent f3ff1d5e1b
commit 939149e8a0
4 changed files with 143 additions and 17 deletions
+12 -1
View File
@@ -514,8 +514,9 @@ def _make_job_key(
regime_filter: str | None = None,
position_policy: str | None = None,
position_policy_params: str | None = None,
force: bool = False,
) -> str:
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}|{commission_pct}|{stamp_tax_pct}|{asset_type}|{minute_fill}|{regime_filter}|{position_policy}|{position_policy_params}"
raw = f"{strategy_id}|{symbols}|{start}|{end}|{matching}|{entry_fill}|{exit_fill}|{fees_pct}|{slippage_bps}|{max_positions}|{max_exposure_pct}|{initial_capital}|{position_sizing}|{params}|{overrides}|{mode}|{holding_days}|{commission_pct}|{stamp_tax_pct}|{asset_type}|{minute_fill}|{regime_filter}|{position_policy}|{position_policy_params}|force={force}"
return hashlib.md5(raw.encode()).hexdigest()[:12]
@@ -546,12 +547,15 @@ async def strategy_stream(
asset_type: str = "stock",
minute_fill: bool = False,
regime_filter: str | None = None,
force: bool = False,
):
"""SSE 流式策略回测: 实时推送进度, 完成后推送结果, 支持重连 (刷新/切页后恢复)。
- 相同参数的任务只启动一次, 多次连接订阅同一个任务
- 断开连接不会取消任务 (除非显式调用 cancel)
- 结果保留 5 分钟供重连
- force=1: 掺入 job_key 使其不命中已完成任务的缓存, 必然重算
(重连/取消仍按 qs 重算同一 key, 语义不变)
事件类型:
- progress: {day, total, date, equity}
@@ -591,6 +595,7 @@ async def strategy_stream(
regime_filter=regime_filter,
position_policy=position_policy,
position_policy_params=position_policy_params,
force=force,
)
_cleanup_stale_jobs()
@@ -740,6 +745,9 @@ async def strategy_cancel(request: Request):
# 可选成本参数: 缺省或空串 → None (与 stream 侧 float | None 口径一致, 保证 job_key 对齐)。
v = _get(key)
return float(v) if v else None
def _get_opt_bool(key: str) -> bool:
# 与 FastAPI 查询参数 bool 解析口径一致 ("1"/"true" 为真, 其余为假)
return _get(key).lower() in ("1", "true")
job_key = _make_job_key(
_get("strategy_id"),
_get("symbols") or None,
@@ -761,8 +769,11 @@ async def strategy_cancel(request: Request):
commission_pct=_get_opt_float("commission_pct"),
stamp_tax_pct=_get_opt_float("stamp_tax_pct"),
asset_type=_get("asset_type", "stock"),
minute_fill=_get_opt_bool("minute_fill"),
regime_filter=_get("regime_filter") or None,
position_policy=_get("position_policy") or None,
position_policy_params=_get("position_policy_params") or None,
force=_get("force") == "1",
)
# 持锁读任务表: 与 _cleanup_stale_jobs 的 pop、stream 的写入互斥
with _jobs_lock:
@@ -0,0 +1,96 @@
"""strategy/stream 的 force 参数 — 绕过同参数任务结果缓存。
SSE 回测按全参数 md5 作 job_key, 已完成任务保留 5 分钟内同 key 连接直接复用
结果 (缓存)。force=1 掺入 job_key 使其不命中缓存必然重算; cancel 从 qs 重算
同一 key, 必须同步解析 force, 否则强制重跑的任务取消不到。
"""
from __future__ import annotations
import threading
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import backtest as bt
from app.api.backtest import router
def _client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_force_changes_job_key_and_is_stable():
args = ("ma_cross", None, "2024-01-01", "2024-12-31", "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
cached = bt._make_job_key(*args)
forced = bt._make_job_key(*args, force=True)
assert cached != forced
# 各自幂等: 同参数重复计算 key 稳定 (重连/取消依赖)
assert bt._make_job_key(*args) == cached
assert bt._make_job_key(*args, force=True) == forced
# force=False 是默认值: 不显式传参与显式 False 同 key
assert bt._make_job_key(*args, force=False) == cached
def test_cancel_resolves_force_key_from_qs(monkeypatch):
"""带 force=1 的任务, cancel 用带 force=1 的 qs 能取消到; 不带 force 的 qs 取消不到。
手工往任务表塞一个未完成 job (key 按 force 计算), 走真实 cancel 端点的
qs→key 解析路径, 不启动任何回测线程。
"""
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
forced_key = bt._make_job_key(*args, force=True)
job = bt._BacktestJob(forced_key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job)
qs_force = "strategy_id=ma_cross&force=1"
resp = _client().post("/api/backtest/strategy/cancel", json={"qs": qs_force})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert job.cancel_event.is_set()
# 不带 force 的 qs 算出的是缓存任务的 key, 取消不到 force 任务
job2 = bt._BacktestJob(forced_key)
job2.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, forced_key, job2)
qs_plain = "strategy_id=ma_cross"
resp2 = _client().post("/api/backtest/strategy/cancel", json={"qs": qs_plain})
assert resp2.status_code == 200
assert resp2.json()["ok"] is False
assert not job2.cancel_event.is_set()
def test_cancel_resolves_minute_fill_and_regime_filter(monkeypatch):
"""minute_fill / regime_filter 掺在 job_key 里, cancel 必须同步解析, 否则取消不到。
曾为预存 bug: stream 侧 key 含这两个参数, cancel 重算漏掉 —— 开了分钟K
精确回测或市场环境过滤的任务停止按钮失效。
"""
from urllib.parse import quote
args = ("ma_cross", None, None, None, "open_t+1", None, None,
0.0002, 5.0, 10, 1.0, 1_000_000.0, "equal", None, None)
regime_json = '{"states":["bull","shake"]}'
key = bt._make_job_key(*args, minute_fill=True, regime_filter=regime_json)
job = bt._BacktestJob(key)
job.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, key, job)
qs = f"strategy_id=ma_cross&minute_fill=true&regime_filter={quote(regime_json)}"
resp = _client().post("/api/backtest/strategy/cancel", json={"qs": qs})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert job.cancel_event.is_set()
# 缺这两个参数的 qs 算出不同 key, 取消不到
job2 = bt._BacktestJob(key)
job2.cancel_event = threading.Event()
monkeypatch.setitem(bt._running_jobs, key, job2)
resp2 = _client().post("/api/backtest/strategy/cancel", json={"qs": "strategy_id=ma_cross"})
assert resp2.status_code == 200
assert resp2.json()["ok"] is False
assert not job2.cancel_event.is_set()
+3
View File
@@ -214,6 +214,8 @@ export function startBacktest(params: {
asset_type?: 'stock' | 'etf'
minute_fill?: boolean
regime_filter?: { states?: string[]; min_score?: number } | null
/** 忽略服务端同参数任务的结果缓存 (5 分钟), 必然重新计算; 重连/取消按 qs 对齐同一任务 */
force?: boolean
}): void {
// 取消之前的任务状态
if (eventSource) {
@@ -248,6 +250,7 @@ export function startBacktest(params: {
asset_type: params.asset_type,
minute_fill: params.minute_fill,
regime_filter: params.regime_filter ? JSON.stringify(params.regime_filter) : undefined,
force: params.force ? 1 : undefined,
})
// 存 reconnect 信息 (刷新后用) + 上报注册表 (其他设备可接回)
@@ -1,7 +1,7 @@
import { useState, useMemo, useEffect, useRef, useCallback, memo, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus, Download, Filter, Star, TrendingUp, CalendarRange } from 'lucide-react'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus, Download, Filter, Star, TrendingUp, CalendarRange, RefreshCw } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
@@ -1242,7 +1242,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
}
}, [backtestTask])
const handleRun = () => {
const handleRun = (force = false) => {
if (!selectedStrategy || backtestDataUnavailable) return
const requestOverrides = detail
? normalizeStrategyOverrides(detail, overrides)
@@ -1277,6 +1277,7 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
...(regimeMinScore !== '' ? { min_score: Number(regimeMinScore) } : {}),
}
: null,
force,
})
}
@@ -1296,7 +1297,9 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
mutationFn: () => api.regimeRecompute(regimeGapStart ?? undefined, end || undefined),
onSuccess: data => {
toast(`环境数据补算完成 (新增 ${data.computed} 天),自动重新回测`, 'success')
handleRun()
// 数据已变, 必须 force: 已失败任务的 error 同样被 5 分钟任务缓存保留,
// 不带 force 会命中同参数旧 job 直接重放补算前的报错
handleRun(true)
},
onError: e => toast(`环境数据补算失败 · ${String((e as Error)?.message || e)}`, 'error'),
})
@@ -2128,19 +2131,32 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
停止回测
</button>
) : (
<button
onClick={handleRun}
disabled={!selectedStrategy || strategyDetail.isLoading || backtestDataUnavailable}
className="group w-full inline-flex items-center justify-center gap-2.5 rounded-btn border border-accent/40
bg-gradient-to-r from-accent to-blue-500 px-3 py-2.5 text-white shadow-[0_10px_24px_rgba(59,130,246,0.22)]
transition-all duration-150 ease-smooth hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(59,130,246,0.28)]
disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/18 ring-1 ring-white/25 transition-transform group-hover:scale-105">
<Play className="h-3.5 w-3.5 translate-x-px fill-current" />
</span>
<span className="text-sm font-semibold tracking-wide">运行回测</span>
</button>
<div className="flex gap-2">
<button
onClick={() => handleRun()}
disabled={!selectedStrategy || strategyDetail.isLoading || backtestDataUnavailable}
className="group min-w-0 flex-1 inline-flex items-center justify-center gap-2.5 rounded-btn border border-accent/40
bg-gradient-to-r from-accent to-blue-500 px-3 py-2.5 text-white shadow-[0_10px_24px_rgba(59,130,246,0.22)]
transition-all duration-150 ease-smooth hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(59,130,246,0.28)]
disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/18 ring-1 ring-white/25 transition-transform group-hover:scale-105">
<Play className="h-3.5 w-3.5 translate-x-px fill-current" />
</span>
<span className="text-sm font-semibold tracking-wide">运行回测</span>
</button>
<button
onClick={() => handleRun(true)}
disabled={!selectedStrategy || strategyDetail.isLoading || backtestDataUnavailable}
title="忽略同参数结果缓存 (保留 5 分钟), 强制重新计算"
className="inline-flex shrink-0 items-center justify-center gap-1.5 rounded-btn border border-border
bg-surface px-3 py-2.5 text-sm font-medium text-secondary transition-colors duration-150 ease-smooth
hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className="h-3.5 w-3.5" />
强制重跑
</button>
</div>
)}
</section>