Files
tick-stock-panel/frontend/src/lib/walkforwardTask.ts
T
im47cn 5ea58f6a8d fix(walkforward): SSE 放弃时清 localStorage + JSON 解析失败告警
跨组件后续修复的 WF 侧 (优化器侧同款问题另起 PR):
- stopWalkForward 延迟分支 + MAX_RECONNECT 超限退出漏清 localStorage, 导致刷新页面
  tryReconnect 会重连到已放弃/未取消的任务。两处补 removeItem(RECONNECT_KEY/JOB_KEY_KEY)。
- walkforward_stream 的 params/overrides JSON 解析失败静默降级为 {}/None, 会让用户配置
  丢失变成无声 bug。升级为 logger.warning (仍降级不崩流, 但可诊断)。
2026-07-12 22:18:11 +08:00

278 lines
7.9 KiB
TypeScript

import { useSyncExternalStore } from 'react'
/** Walk-forward 任务管理 (SSE + job_key 回吐 + 重连)。镜像 optimizerTask。 */
export interface WFProgress {
type: string
done: number
total: number
fold: number
}
export interface WFFold {
index: number
train_start: string
train_end: string
test_start: string
test_end: string
best_params: Record<string, any> | null
is_score: number | null
oos_objective: number | null
oos_degraded: boolean | null
oos_stats: Record<string, any>
}
export interface WFSummary {
n_folds: number
compounded_oos_return: number
avg_is_objective: number | null
avg_oos_objective: number | null
degradation: number | null
consistency: number
oos_equity_curve: { fold: number; date: string; value: number }[]
}
export interface WalkForwardResult {
objective: string
direction: string
n_folds: number
n_skipped: number
n_planned_folds: number
folds: WFFold[]
skipped: { index: number; test_start: string; test_end: string; reason: string }[]
summary: WFSummary
elapsed_ms: number
}
export interface WalkForwardTask {
id: number
isPending: boolean
result: WalkForwardResult | null
progress: WFProgress | null
error: string | null
}
export interface StartWalkForwardParams {
strategy_id: string
param_grid: Record<string, any>
objective: string
train_days: number
test_days: number
step_days: number
params?: Record<string, any> | null // 未扫描参数固定为用户当前值
overrides?: Record<string, any> | null // 策略当前的 basic_filter/信号/风控覆盖
symbols?: string[] | null
start?: string | null
end?: string | null
mode?: 'position' | 'full'
}
let current: WalkForwardTask | null = null
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'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const sp = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') sp.set(k, String(v))
}
return sp.toString()
}
function connectSSE(url: string): void {
const id = current?.id ?? ++taskSeq
if (eventSource) {
eventSource.close()
eventSource = null
}
const es = new EventSource(url)
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 }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as WalkForwardResult
current = { ...current, isPending: false, result, error: null }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败' }
emit()
}
es.close()
eventSource = null
currentJobKey = null
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? 'walk-forward 出错'
current = { ...current, isPending: false, error: msg }
emit()
} catch {
current = { ...current, isPending: false, error: 'walk-forward 出错' }
emit()
}
es.close()
eventSource = null
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
// 清 localStorage: 否则刷新页面 tryReconnect 会重连到这个已放弃的任务。
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
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()
const qs = buildQuery({
strategy_id: params.strategy_id,
param_grid: JSON.stringify(params.param_grid),
objective: params.objective,
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,
mode: params.mode,
})
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
}
export function stopWalkForward(): void {
// 竞态: job_key 未到手时保持 SSE 打开, 等 job 事件补发 cancel (关 SSE 不停后端 daemon 线程)。
cancelRequested = true
const jobKey = currentJobKey ?? localStorage.getItem(JOB_KEY_KEY)
if (jobKey) {
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
// job_key 始终没到手(job 事件未达): 5 秒后放弃并清 localStorage, 避免刷新重连到未取消任务。
// (若期间 job 到达, job handler 已 postCancel+清storage 并置 eventSource=null, 下面条件不成立跳过)
setTimeout(() => {
if (es === eventSource) {
es.close(); eventSource = null
localStorage.removeItem(RECONNECT_KEY)
localStorage.removeItem(JOB_KEY_KEY)
}
}, 5000)
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消' }
emit()
}
}
export function clearWalkForward(): void {
current = null
emit()
}
export function tryReconnectWalkForward(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null }
emit()
connectSSE(`/api/backtest/walkforward/stream?${qs}`)
return true
}
export function useWalkForwardTask(): WalkForwardTask | null {
return useSyncExternalStore(subscribe, () => current, () => null)
}