From 72697707a4499085a0699fc28e78e52ca11b6f56 Mon Sep 17 00:00:00 2001 From: im47cn <67424112+im47cn@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:52:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(walkforward):=20=E4=BC=98=E5=8C=96=E5=99=A8?= =?UTF-8?q?=E5=90=83=E7=94=A8=E6=88=B7=E5=BD=93=E5=89=8D=E7=AD=96=E7=95=A5?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=20+=20EmptyState=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WF 前端 onRun 只传了 grid/objective/窗口, 没传当前策略的 params/overrides: - 未扫描参数不固定为用户当前值 - basic_filter/entry_signals/exit_signals/止损止盈/评分等覆盖不参与优化 结果 walk-forward 优化的策略与用户实际回测的策略不一致 (与 PR #82 优化器 阻塞项同类问题)。修复: onRun 补 params=selected.params_defaults + overrides=buildDefaultOverrides(selected), 复用 optimizer 同一工具。 附带: - StrategyWalkForward.tsx 用了 EmptyState description= (该组件只接受 title/hint), 导致 tsc -b 构建失败, 2 处改为 hint。 - _make_wf_job_key 已含 params/overrides, 补测试断言二者不同产出不同 job_key (stream 与 cancel 对齐前提) 且相同入参稳定一致。 --- backend/app/api/backtest.py | 18 ++++- backend/tests/backtest/test_walkforward.py | 16 +++++ frontend/src/lib/walkforwardTask.ts | 65 +++++++++++++++---- .../pages/backtest/StrategyWalkForward.tsx | 9 ++- 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/backend/app/api/backtest.py b/backend/app/api/backtest.py index abe2e5a..96dfb4a 100644 --- a/backend/app/api/backtest.py +++ b/backend/app/api/backtest.py @@ -738,8 +738,8 @@ async def optimize_cancel(request: Request): # Walk-forward 优化 — 每折训练区间优化 + 测试区间 OOS 验证 (复用优化器 + job_key 回吐) # ══════════════════════════════════════════════════════════════ -def _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig) -> str: - raw = f"WF|{strategy_id}|{symbols}|{start}|{end}|{param_grid}|{objective}|{direction}|{windows}|{bt_sig}" +def _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig, params=None, overrides=None) -> str: + raw = f"WF|{strategy_id}|{symbols}|{start}|{end}|{param_grid}|{objective}|{direction}|{windows}|{bt_sig}|{params}|{overrides}" return hashlib.md5(raw.encode()).hexdigest()[:12] @@ -754,6 +754,8 @@ async def walkforward_stream( test_days: int = 63, step_days: int = 63, max_workers: int = 4, + params: str | None = None, # JSON: 未扫描参数固定为用户当前值 (base_params) + overrides: str | None = None, # JSON: 策略当前的 basic_filter/signals/风控等覆盖 symbols: str | None = None, start: str | None = None, end: str | None = None, @@ -796,7 +798,7 @@ async def walkforward_stream( ) bt_sig = "|".join(f"{k}={bt_kwargs[k]}" for k in _OPT_BT_FIELDS) windows = f"{train_days}/{test_days}/{step_days}" - job_key = _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig) + job_key = _make_wf_job_key(strategy_id, symbols, start, end, param_grid, objective, direction, windows, bt_sig, params, overrides) # guard 作用于单折窗口 (每折训练/测试各是一次回测), 而非总区间 —— WF 总区间可长达数年, # 按总区间拦会误杀; 真正的 OOM 风险在单折窗口过大。 @@ -835,6 +837,14 @@ async def walkforward_stream( grid = None if grid is not None: + try: + base_params = json.loads(params) if params else {} + except (json.JSONDecodeError, TypeError): + base_params = {} + try: + ov = json.loads(overrides) if overrides else None + except (json.JSONDecodeError, TypeError): + ov = None wf_cfg = WalkForwardConfig( strategy_id=strategy_id, symbols=[s.strip() for s in symbols.split(",") if s.strip()] if symbols else None, @@ -847,6 +857,8 @@ async def walkforward_stream( test_days=int(test_days), step_days=int(step_days), max_workers=int(max_workers), + base_params=base_params if isinstance(base_params, dict) else {}, + overrides=ov if isinstance(ov, dict) else None, backtest_kwargs=bt_kwargs, ) diff --git a/backend/tests/backtest/test_walkforward.py b/backend/tests/backtest/test_walkforward.py index 036c730..fb09d0c 100644 --- a/backend/tests/backtest/test_walkforward.py +++ b/backend/tests/backtest/test_walkforward.py @@ -240,6 +240,22 @@ def test_wf_job_key_distinguishes_windows(): assert base != _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "120/30/30", "sig") +def test_wf_job_key_distinguishes_params_and_overrides(): + """params/overrides 不同必须产出不同 job_key —— 否则 stream 与 cancel 会错配到别的任务。""" + from app.api.backtest import _make_wf_job_key + base = _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig") + # params 不同 (未扫描参数固定值不同 -> 优化的策略不同) + assert base != _make_wf_job_key( + "s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}') + # overrides 不同 (basic_filter/信号/风控 不同) + assert base != _make_wf_job_key( + "s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", overrides='{"score_min":5}') + # 相同 params/overrides 必须稳定一致 (stream 端与 cancel 端对齐前提) + k = _make_wf_job_key("s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}') + assert k == _make_wf_job_key( + "s", None, None, None, '{"p":[1]}', "sortino", None, "252/63/63", "sig", params='{"x":1}') + + def test_wf_cancel_by_echoed_key(): import asyncio diff --git a/frontend/src/lib/walkforwardTask.ts b/frontend/src/lib/walkforwardTask.ts index 50d48d9..4e26413 100644 --- a/frontend/src/lib/walkforwardTask.ts +++ b/frontend/src/lib/walkforwardTask.ts @@ -59,6 +59,8 @@ export interface StartWalkForwardParams { train_days: number test_days: number step_days: number + params?: Record | null // 未扫描参数固定为用户当前值 + overrides?: Record | null // 策略当前的 basic_filter/信号/风控覆盖 symbols?: string[] | null start?: string | null end?: string | null @@ -70,6 +72,9 @@ const listeners = new Set<() => void>() let taskSeq = 0 let eventSource: EventSource | null = null let currentJobKey: string | null = null +let cancelRequested = false +let reconnectAttempts = 0 +const MAX_RECONNECT = 5 const RECONNECT_KEY = 'walkforward_reconnect' const JOB_KEY_KEY = 'walkforward_job_key' @@ -103,17 +108,28 @@ function connectSSE(url: string): void { eventSource = es es.addEventListener('job', (e: MessageEvent) => { + reconnectAttempts = 0 try { const key = JSON.parse(e.data)?.key if (key) { currentJobKey = key localStorage.setItem(JOB_KEY_KEY, key) + // 竞态: stop 在拿到 key 前被点过 -> 补发 cancel 真正停后端任务, 再收尾关闭。 + if (cancelRequested) { + postCancel(key) + es.close() + eventSource = null + currentJobKey = null + localStorage.removeItem(RECONNECT_KEY) + localStorage.removeItem(JOB_KEY_KEY) + } } } catch { /* ignore */ } }) es.addEventListener('progress', (e: MessageEvent) => { if (current?.id !== id) return + reconnectAttempts = 0 try { const prog = JSON.parse(e.data) as WFProgress current = { ...current, progress: prog } @@ -154,16 +170,39 @@ function connectSSE(url: string): void { currentJobKey = null localStorage.removeItem(RECONNECT_KEY) localStorage.removeItem(JOB_KEY_KEY) + return + } + // 无 data: 连接异常断开。EventSource 自动重连, 设上限避免网络长断时无限 pending。 + if (current?.id === id) { + reconnectAttempts += 1 + if (reconnectAttempts > MAX_RECONNECT) { + es.close() + eventSource = null + current = { ...current, isPending: false, error: '连接中断, 重连多次失败' } + emit() + } } }) } +/** 调后端 cancel (按回吐的 job_key)。 */ +function postCancel(jobKey: string): void { + fetch('/api/backtest/walkforward/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_key: jobKey }), + }).catch(() => {}) +} + export function startWalkForward(params: StartWalkForwardParams): void { if (eventSource) { eventSource.close() eventSource = null } + cancelRequested = false + currentJobKey = null + reconnectAttempts = 0 const id = ++taskSeq current = { id, isPending: true, result: null, progress: null, error: null } emit() @@ -175,6 +214,8 @@ export function startWalkForward(params: StartWalkForwardParams): void { train_days: params.train_days, test_days: params.test_days, step_days: params.step_days, + params: params.params ? JSON.stringify(params.params) : undefined, + overrides: params.overrides ? JSON.stringify(params.overrides) : undefined, symbols: params.symbols?.join(','), start: params.start ?? undefined, end: params.end ?? undefined, @@ -185,26 +226,24 @@ export function startWalkForward(params: StartWalkForwardParams): void { connectSSE(`/api/backtest/walkforward/stream?${qs}`) } -export async function stopWalkForward(): Promise { +export function stopWalkForward(): void { + // 竞态: job_key 未到手时保持 SSE 打开, 等 job 事件补发 cancel (关 SSE 不停后端 daemon 线程)。 + cancelRequested = true const jobKey = currentJobKey ?? localStorage.getItem(JOB_KEY_KEY) if (jobKey) { - await fetch('/api/backtest/walkforward/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ job_key: jobKey }), - }).catch(() => {}) - } - if (eventSource) { - eventSource.close() - eventSource = null + postCancel(jobKey) + if (eventSource) { eventSource.close(); eventSource = null } + currentJobKey = null + localStorage.removeItem(RECONNECT_KEY) + localStorage.removeItem(JOB_KEY_KEY) + } else if (eventSource) { + const es = eventSource + setTimeout(() => { if (es === eventSource) { es.close(); eventSource = null } }, 5000) } if (current?.isPending) { current = { ...current, isPending: false, error: '已取消' } emit() } - currentJobKey = null - localStorage.removeItem(RECONNECT_KEY) - localStorage.removeItem(JOB_KEY_KEY) } export function clearWalkForward(): void { diff --git a/frontend/src/pages/backtest/StrategyWalkForward.tsx b/frontend/src/pages/backtest/StrategyWalkForward.tsx index ee5787a..d61fda2 100644 --- a/frontend/src/pages/backtest/StrategyWalkForward.tsx +++ b/frontend/src/pages/backtest/StrategyWalkForward.tsx @@ -12,6 +12,7 @@ import { tryReconnectWalkForward, useWalkForwardTask, } from '@/lib/walkforwardTask' +import { buildDefaultOverrides } from '@/lib/strategyOverrides' import { INPUT_CLS, OBJECTIVES, @@ -93,6 +94,10 @@ export function StrategyWalkForward() { train_days: Number(trainDays), test_days: Number(testDays), step_days: Number(stepDays), + // 未扫描参数固定为策略当前默认值; overrides 让 basic_filter/信号/风控按当前策略参与, + // 否则 walk-forward 优化的策略与用户实际回测的不一致 (同 PR #82 优化器修复)。 + params: sweep.selected?.params_defaults, + overrides: sweep.selected ? buildDefaultOverrides(sweep.selected) : undefined, start, end, mode, @@ -189,13 +194,13 @@ export function StrategyWalkForward() { {!result && !task?.isPending && ( )} {result && result.n_folds === 0 && ( + hint={`计划 ${result.n_planned_folds} 折, 但 ${result.n_skipped} 折因训练区间未优化出参数或 OOS 回测失败被跳过。请检查数据范围或放宽参数网格。`} /> )} {result && summary && result.n_folds > 0 && (