diff --git a/frontend/src/pages/backtest/MiningWorkbench.tsx b/frontend/src/pages/backtest/MiningWorkbench.tsx index 9a61a3c..a270f2b 100644 --- a/frontend/src/pages/backtest/MiningWorkbench.tsx +++ b/frontend/src/pages/backtest/MiningWorkbench.tsx @@ -58,6 +58,23 @@ const PROFILE_LABELS: Record = { balanced: '均衡档', strict: '严格档', } +// 市场环境覆盖不足的失败文案 (regime_alignment 校验抛出), 命中时弹「补算并重跑」确认。 +const REGIME_COVERAGE_ERROR_RE = /市场环境数据覆盖不完整|市场环境数据为空/ +const REGIME_FIRST_MISSING_RE = /缺少前一交易日环境\s*(\d{4}-\d{2}-\d{2})/ + +/** run.request 带 auto/auto_screening 附加字段, 而 start 接口 extra="forbid", 重跑前剥回纯 MiningRequestV1。 */ +function stripMiningRequestExtras(request: MiningRun['request']): MiningRequestV1 { + const { + factor_names, strategy_ids, symbols, asset_type, start, end, budget_profile, + commission_pct, stamp_tax_pct, slippage_bps, correlation_threshold, + max_combination_factors, beam_width, max_finalists, force, + } = request + return { + factor_names, strategy_ids, symbols, asset_type, start, end, budget_profile, + commission_pct, stamp_tax_pct, slippage_bps, correlation_threshold, + max_combination_factors, beam_width, max_finalists, force, + } +} interface MiningDraft { assetType: 'stock' | 'etf' @@ -606,6 +623,43 @@ export function MiningWorkbench() { onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'), }) + // 市场环境覆盖不足: 挖矿内部 T-1 环境校验 (fail-closed) 失败时弹「补算并重跑」确认。 + // 确定 → 调 regime/recompute 补算缺失区间后自动重跑同一 payload; 取消 → 仅关闭不动作。 + const [regimeBackfill, setRegimeBackfill] = useState<{ payload: MiningRequestV1; start?: string } | null>(null) + const regimeErrorHandled = useRef('') + useEffect(() => { + if (task.isPending) { + regimeErrorHandled.current = '' + return + } + if (!task.error || !REGIME_COVERAGE_ERROR_RE.test(task.error)) return + if (regimeErrorHandled.current === task.error) return + const request = task.run?.request + if (!request) return + regimeErrorHandled.current = task.error + setRegimeBackfill({ + payload: stripMiningRequestExtras(request), + start: REGIME_FIRST_MISSING_RE.exec(task.error)?.[1], + }) + }, [task.error, task.isPending, task.run]) + const regimeRecomputeAndRun = useMutation({ + mutationFn: async (job: { payload: MiningRequestV1; start?: string }) => { + // start 缺省(环境数据为空)时后端从 enriched 最早日全量重算; 有 start 则只补缺口区间 + const recomputed = await api.regimeRecompute(job.start) + return { recomputed, job } + }, + onSuccess: ({ recomputed, job }) => { + for (const key of [QK.regimeLatest, QK.regimeCoverage, QK.regimeHistory()]) { + queryClient.invalidateQueries({ queryKey: key }) + } + toast(recomputed.computed > 0 ? `环境补算完成 · 新增 ${recomputed.computed} 天,重新开始挖掘` : '环境数据已是最新,重新开始挖掘', 'success') + setRegimeBackfill(null) + submitMining(job.payload) + }, + onError: error => toast(`环境补算失败 · ${String((error as Error).message || error)}`, 'error'), + }) + const regimeBackdrop = useDialogBackdrop(() => setRegimeBackfill(null), () => !regimeRecomputeAndRun.isPending) + const attachRun = (run: MiningRun) => { const params = new URLSearchParams(searchParams) params.set('run', run.run_id) @@ -814,6 +868,30 @@ export function MiningWorkbench() { )} + + {/* 市场环境覆盖不足的补救确认: 补算缺失区间后自动重跑, 取消则不做任何操作。 */} + {regimeBackfill && ( +
+
e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl"> +
+ + 市场环境数据不完整 +
+

+ {regimeBackfill.start + ? <>环境数据缺少 {regimeBackfill.start} 起的部分交易日,挖掘的 T-1 环境校验未通过。点击确定将自动补算该区间(结束至今日),完成后重新开始挖掘。 + : '尚未计算市场环境数据。点击确定将执行全量计算(历史较长时耗时较久),完成后重新开始挖掘。'} +

+
+ + +
+
+
+ )} ) }