Files
tick-stock-panel/frontend/src/lib/backtestTask.ts
T
shy3130 d9f7bc645d feat(backtest): 回测页支持分钟策略与分钟级成交展示
- 策略下拉改取 'all' 时段, 分钟策略入列表并加 sky 色「分钟」徽章
- 选中分钟策略条件化 UI: 隐藏分钟成交开关与 signal_next_minute,
  建仓口径固定显示「信号分钟收盘」, 展示本地分钟K覆盖提示与起始日越界警告
- 交易明细买入列渲染 HH:MM 分钟徽章 (entry_date 携带 YYYY-MM-DD HH:MM)
- SSE 连接前先 fetch 探测, HTTP 400 直接展示后端 detail,
  不再误入 EventSource 无限重连
2026-08-30 19:05:17 +08:00

299 lines
9.4 KiB
TypeScript

import { useSyncExternalStore } from 'react'
import type { StrategyBacktestResult } from './api'
/**
* 全局回测任务管理 (SSE 模式 + 任务缓存 + 重连支持)。
*
* 特性:
* - 实时进度: EventSource 监听后端 SSE, 推送 day/total/equity
* - 可取消: POST /strategy/cancel/{job_key}, 后端 cancel_event
* - 切页/刷新保持: 后端按参数 hash 缓存任务, 重连不重启
* - 切页: 模块级 store 保持, EventSource 随组件卸载断开, 回来后重连
* - 刷新: localStorage 存 job 参数, 刷新后重新连接到同一任务
*/
export interface BacktestProgress {
day: number
total: number
date: string
equity: number
}
export interface BacktestTask {
id: number
isPending: boolean
result: StrategyBacktestResult | null
progress: BacktestProgress | null
error: string | null
/** 连接中断、正在有界重连中 (UI 显示"连接中断,重试中") */
reconnecting: boolean
}
// 连接断开后最多自动重连次数, 超过则放弃并进入可重试的错误态
const MAX_RECONNECT_ATTEMPTS = 5
let current: BacktestTask | null = null
const listeners = new Set<() => void>()
let taskSeq = 0
let eventSource: EventSource | null = null
const RECONNECT_KEY = 'backtest_reconnect'
function emit() {
listeners.forEach(fn => fn())
}
function subscribe(fn: () => void) {
listeners.add(fn)
return () => listeners.delete(fn)
}
function getSnapshot() {
return current
}
function getServerSnapshot() {
return null
}
/** 查询字符串构建 */
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()
}
/** 连接 SSE (新建或重连都用这个) */
async function connectSSE(url: string): Promise<void> {
const id = current?.id ?? ++taskSeq
// 关闭旧连接
if (eventSource) {
eventSource.close()
eventSource = null
}
// 预检: 4xx 拒绝 (如分钟策略回测的分钟K覆盖守卫 400) 时 EventSource 只会无 data 地
// onerror, 会被当成"连接中断"有界重试 — 先 fetch 一次把后端 detail 直接展示给用户。
try {
const probe = await fetch(url, { headers: { Accept: 'text/event-stream' } })
if (!probe.ok) {
let message = `回测请求失败 (${probe.status})`
try {
message = (await probe.json())?.detail ?? message
} catch { /* ignore */ }
await probe.body?.cancel().catch(() => {})
if (current?.id === id) {
current = { ...current, isPending: false, error: message, reconnecting: false }
emit()
localStorage.removeItem(RECONNECT_KEY)
}
return
}
await probe.body?.cancel().catch(() => {})
} catch { /* 网络层异常: 交给下方 EventSource 的重连逻辑 */ }
const es = new EventSource(url)
eventSource = es
// 本次连接的重连计数 (EventSource 断开会自动重连并再次触发 onerror)
let reconnectAttempts = 0
const clearReconnecting = () => {
if (current?.id === id && current.reconnecting) {
current = { ...current, reconnecting: false }
emit()
}
reconnectAttempts = 0
}
es.onopen = () => {
clearReconnecting()
}
es.addEventListener('progress', (e: MessageEvent) => {
if (current?.id !== id) return
// 收到数据说明连接恢复正常
reconnectAttempts = 0
try {
const prog = JSON.parse(e.data) as BacktestProgress
current = { ...current, progress: prog, reconnecting: false }
emit()
} catch { /* ignore */ }
})
es.addEventListener('done', (e: MessageEvent) => {
if (current?.id !== id) return
try {
const result = JSON.parse(e.data) as StrategyBacktestResult
current = { ...current, isPending: false, result, error: null, reconnecting: false }
emit()
} catch {
current = { ...current, isPending: false, error: '结果解析失败', reconnecting: false }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
})
es.addEventListener('error', (e: MessageEvent) => {
if (current?.id !== id) return
// SSE error 事件: 有 data 说明是后端主动推送的错误/取消; 无 data 说明是连接断开
if (e.data) {
try {
const msg = JSON.parse(e.data)?.message ?? '回测出错'
current = { ...current, isPending: false, error: msg, reconnecting: false }
emit()
} catch {
current = { ...current, isPending: false, error: '回测出错', reconnecting: false }
emit()
}
es.close()
eventSource = null
localStorage.removeItem(RECONNECT_KEY)
return
}
// 无 data: 连接异常断开。EventSource 会自动重连, 但需给出可见状态并有界放弃,
// 避免进度条永久冻结、isPending 永远 true。
reconnectAttempts += 1
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
// 放弃: 停止自动重连, 进入可重试的错误态 (用户可重新发起回测)
es.close()
eventSource = null
current = {
...current,
isPending: false,
reconnecting: false,
error: '连接中断,请重试',
}
emit()
return
}
// 仍在重试窗口内: 标记 reconnecting, 让 UI 显示"连接中断,重试中"
current = { ...current, reconnecting: true }
emit()
})
}
/** 启动一次 SSE 回测任务 */
export function startBacktest(params: {
strategy_id: string
symbols?: string[] | null
start?: string | null
end?: string | null
matching?: string
entry_fill?: string
exit_fill?: string
fees_pct?: number
commission_pct?: number
stamp_tax_pct?: number
slippage_bps?: number
max_positions?: number
max_exposure_pct?: number
initial_capital?: number
position_sizing?: string
params?: Record<string, any> | null
overrides?: Record<string, any> | null
mode?: 'position' | 'full'
holding_days?: number
asset_type?: 'stock' | 'etf'
minute_fill?: boolean
regime_filter?: { states?: string[]; min_score?: number } | null
}): void {
// 取消之前的任务状态
if (eventSource) {
eventSource.close()
eventSource = null
}
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null, reconnecting: false }
emit()
const qs = buildQuery({
strategy_id: params.strategy_id,
symbols: params.symbols?.join(','),
start: params.start ?? undefined,
end: params.end ?? undefined,
matching: params.matching,
entry_fill: params.entry_fill,
exit_fill: params.exit_fill,
fees_pct: params.fees_pct,
commission_pct: params.commission_pct,
stamp_tax_pct: params.stamp_tax_pct,
slippage_bps: params.slippage_bps,
max_positions: params.max_positions,
max_exposure_pct: params.max_exposure_pct,
initial_capital: params.initial_capital,
position_sizing: params.position_sizing,
params: params.params ? JSON.stringify(params.params) : undefined,
overrides: params.overrides ? JSON.stringify(params.overrides) : undefined,
mode: params.mode,
holding_days: params.holding_days,
asset_type: params.asset_type,
minute_fill: params.minute_fill,
regime_filter: params.regime_filter ? JSON.stringify(params.regime_filter) : undefined,
})
// 存 reconnect 信息 (刷新后用)
localStorage.setItem(RECONNECT_KEY, qs)
connectSSE(`/api/backtest/strategy/stream?${qs}`)
}
/** 停止当前回测任务 (调后端 cancel, 后端 cancel_event → 停止计算) */
export async function stopBacktest(): Promise<void> {
// 从 reconnect key 提取 job_key (后端按参数 hash 算 job_key)
const qs = localStorage.getItem(RECONNECT_KEY)
if (qs) {
// 解析出参数, 用 fetch 调 cancel
try {
// job_key 是后端算的 md5, 前端不知道。用 reconnect URL 里的参数重新请求 stream,
// 后端会找到同一个 job 并返回它的 job_key? 不行。
// 替代: 前端直接关闭 SSE 连接 + 调一个带参数的 cancel 接口。
// 简化: 关闭连接即可, 后端检测断开后 (不取消)。需要 cancel 用 POST。
// 这里用 cancel 接口: POST /strategy/cancel, body 带 qs 的参数。
await fetch('/api/backtest/strategy/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ qs }),
}).catch(() => {})
} catch { /* ignore */ }
}
if (eventSource) {
eventSource.close()
eventSource = null
}
if (current?.isPending) {
current = { ...current, isPending: false, error: '已取消', reconnecting: false }
emit()
}
localStorage.removeItem(RECONNECT_KEY)
}
/** 清除任务状态 (隐藏提示) */
export function clearBacktest(): void {
current = null
emit()
}
/** 恢复: 从 localStorage 读取 reconnect 信息, 重新连接 (刷新后调用) */
export function tryReconnect(): boolean {
const qs = localStorage.getItem(RECONNECT_KEY)
if (!qs) return false
// 有未完成的任务, 重连
const id = ++taskSeq
current = { id, isPending: true, result: null, progress: null, error: null, reconnecting: false }
emit()
connectSSE(`/api/backtest/strategy/stream?${qs}`)
return true
}
/** React hook: 读取当前全局回测任务状态 */
export function useBacktestTask(): BacktestTask | null {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
}