fix(backtest): 预热/同步期间统计为 null 不再误判缺数据禁用回测

backtestDataUnavailable 原判定 dataStatus.isSuccess && !earliestDate 在
busy 降级期间 (预热 indicators_ready=false / 同步修复 stats_pending=true,
enriched 统计为 null 是未知态) 恒为 true — 回测按钮被禁 + 常驻"缺少指标
数据, 请先同步"横幅, 与 Dashboard 已修的 warmup 误判同族 (stats_pending
契约: 空值不代表无数据)。补 statsPending 放行分支, 真实统计就绪且确无
数据时行为不变。

验证: StrategyBacktest.status.test 2 条 (busy 期无横幅 + 渲染无 TypeError;
就绪确无数据仍提示并禁用 — 修复前红/修复后绿); 全量 vitest 340 passed;
pnpm build 通过。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 13:17:39 +08:00
parent c249c6b5b1
commit 9659237a15
2 changed files with 111 additions and 1 deletions
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
// 回归: 预热/同步/修复期间 (stats_pending / indicators_ready=false) 统计为 null 是未知态,
// 不得误判"缺少指标数据"禁用运行回测按钮 (同 Dashboard 的 warmup 误判一族);
// 真实统计就绪且确无数据时仍要禁用 + 提示。
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { MemoryRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { StrategyBacktest } from './StrategyBacktest'
import type { DataStatus } from '@/lib/api'
vi.mock('@/lib/api', () => ({
api: {
dataStatus: vi.fn(),
screenerStrategies: vi.fn(async () => ({ presets: [] })),
strategyGet: vi.fn(async () => ({})),
watchlistList: vi.fn(async () => ({ items: [] })),
positionPolicyList: vi.fn(async () => ({ policies: [] })),
factorColumns: vi.fn(async () => ({ columns: [] })),
factorLibrary: vi.fn(async () => ({ factors: [] })),
instrumentSearch: vi.fn(async () => ({ results: [] })),
customSignalsList: vi.fn(async () => ({ signals: [] })),
customSignalsOptions: vi.fn(async () => ({ fields: [], operators: [] })),
capabilities: vi.fn(async () => ({ capabilities: {} })),
settings: vi.fn(async () => ({ onboarding_completed: true, mode: 'api_key', has_tickflow_key: true })),
preferences: vi.fn(async () => ({})),
dataSources: vi.fn(async () => []),
regimeRecompute: vi.fn(async () => ({ ok: true, computed: 0 })),
strategySaveConfig: vi.fn(async () => ({ ok: true })),
researchCandidateCreate: vi.fn(async () => ({})),
},
REGIME_STATE_LABELS: {} as Record<string, string>,
REGIME_STATE_COLORS: {} as Record<string, string>,
}))
vi.mock('@/lib/backtestTask', () => ({
startBacktest: vi.fn(),
stopBacktest: vi.fn(async () => {}),
tryReconnect: vi.fn(() => false),
clearBacktest: vi.fn(),
useBacktestTask: () => null,
}))
let host: HTMLDivElement
let root: Root
let client: QueryClient
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
sessionStorage.clear()
host = document.createElement('div')
document.body.append(host)
root = createRoot(host)
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
afterEach(async () => {
await act(async () => root.unmount())
client.clear()
host.remove()
vi.restoreAllMocks()
})
const busy = {
indicators_ready: false, stats_pending: true,
daily: null, enriched: null, etf_enriched: null,
} as unknown as DataStatus
const readyNoData = {
indicators_ready: true, stats_pending: false,
daily: null, enriched: null, etf_enriched: null,
} as unknown as DataStatus
async function renderPage() {
const errors: unknown[] = []
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { errors.push(args) })
await act(async () => root.render(
<MemoryRouter><QueryClientProvider client={client}><StrategyBacktest /></QueryClientProvider></MemoryRouter>,
))
await act(async () => { await new Promise(r => setTimeout(r, 80)) })
spy.mockRestore()
return errors
}
function runButton(): HTMLButtonElement | undefined {
return [...host.querySelectorAll('button')].find(b => b.textContent?.includes('运行回测'))
}
it('预热/同步期间统计为 null: 不提示缺数据, 回测按钮可用', async () => {
const { api } = await import('@/lib/api')
vi.mocked(api.dataStatus).mockResolvedValue(busy)
const errors = await renderPage()
// 核心误判: busy 未知态不得显示"缺少指标数据"横幅 (backtestDataUnavailable=false,
// 按钮的 disabled 另受"未选策略"影响, 不在此断言)
expect(host.textContent).not.toContain('缺少股票指标数据')
// 渲染期 TypeError 防线 (与数据页崩溃修复同族守护)
expect(errors.some(e => String(e).includes('TypeError'))).toBe(false)
})
it('真实统计就绪且确无数据: 提示缺数据并禁用回测', async () => {
const { api } = await import('@/lib/api')
vi.mocked(api.dataStatus).mockResolvedValue(readyNoData)
await renderPage()
expect(host.textContent).toContain('缺少股票指标数据')
expect(runButton()!.disabled).toBe(true)
})
@@ -1147,7 +1147,10 @@ export function StrategyBacktest({ loadCandidate, onLoadConsumed }: {
? dataStatus.data?.etf_enriched
: dataStatus.data?.enriched
const earliestDate = backtestDataStatus?.earliest_date ?? null
const backtestDataUnavailable = dataStatus.isSuccess && !earliestDate
// 预热/同步/修复期间统计为 null 是未知态 (stats_pending 契约, 同 Dashboard.isDataStatusPending),
// 不得误判成"缺数据"禁用回测 — 等真实统计就绪再判
const statsPending = dataStatus.data?.stats_pending === true || dataStatus.data?.indicators_ready === false
const backtestDataUnavailable = dataStatus.isSuccess && !statsPending && !earliestDate
const backtestDataLabel = assetType === 'etf' ? 'ETF 指标数据' : '股票指标数据'
const resetConfigFromDetail = (detail: StrategyDetail) => {