mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
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:
@@ -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®ime_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()
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user