Files
tick-stock-panel/frontend/src/pages/backtest/MiningWorkbench.tsx
T
shy3130 7ac0456c53 feat(mining): 实时行情与挖掘的冲突确认弹窗
盘中实时开启时 enriched 每轮落盘推进数据世代, 排队中的挖掘任务
开跑会因首轮世代校验失败而报错 (mining data generation changed
after the run was queued, 用户反馈)。两个方向加确认弹窗:

- 挖掘页: 实时开启时点「开始挖掘」→ 三按钮确认 (取消 / 仍要开始 /
  关闭实时并开始 — 后者一键关实时并提交任务)
- 侧边栏实时开关: 存在 queued 挖掘任务时开启 → 确认弹窗;
  运行中任务自动跟随新世代, 不拦截

验证: tsc 零错误 + build 通过 + 浏览器验收 7/7 (弹窗出现/取消
无副作用/关实时并启动任务链路/无排队任务不拦截/环境恢复)。
2026-09-02 15:20:48 +08:00

761 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link, useSearchParams } from 'react-router-dom'
import {
AlertTriangle,
CheckCircle2,
Clock3,
Database,
FlaskConical,
Gauge,
Link2,
LoaderCircle,
Play,
RefreshCw,
Rocket,
Save,
Settings2,
Square,
} from 'lucide-react'
import { EmptyState } from '@/components/EmptyState'
import { toast } from '@/components/Toast'
import {
api,
type FactorColumn,
type MiningBudgetProfile,
type MiningCandidateGate,
type MiningCandidateRow,
type MiningRequestV1,
type MiningResult,
type MiningRun,
type MiningScheduleConfig,
} from '@/lib/api'
import {
attachMiningRun,
cancelMining,
startMining,
tryReconnectMining,
useMiningTask,
} from '@/lib/miningTask'
import { QK } from '@/lib/queryKeys'
import { usePreferences } from '@/lib/useSharedQueries'
import { useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { FactorCorrelationHeatmap } from './charts/FactorCorrelationHeatmap'
import { MiningOosChart } from './charts/MiningOosChart'
import { RegimeComparisonChart } from './charts/RegimeComparisonChart'
const DRAFT_KEY = 'mining_workbench_draft_v1'
const TODAY = new Date().toISOString().slice(0, 10)
const INPUT = 'h-8 w-full rounded-input border border-border bg-surface px-2 text-xs text-foreground outline-none transition-colors focus:border-accent'
const LABEL = 'mb-1 block text-[10px] font-medium text-secondary'
const SUCCESS = new Set(['succeeded', 'succeeded_with_budget_exhausted'])
const ACTIVE = new Set(['queued', 'running', 'cancelling'])
const PROFILE_LABELS: Record<MiningBudgetProfile, string> = {
exploratory: '探索档',
balanced: '均衡档',
strict: '严格档',
}
interface MiningDraft {
assetType: 'stock' | 'etf'
start: string
end: string
profile: MiningBudgetProfile
factorNames: string[]
strategyIds: string[]
commissionBps: string
stampTaxBps: string
slippageBps: string
correlationThreshold: string
maxCombinationFactors: string
beamWidth: string
maxFinalists: string
force: boolean
}
function yearsAgo(years: number) {
const value = new Date()
value.setFullYear(value.getFullYear() - years)
return value.toISOString().slice(0, 10)
}
const DEFAULT_DRAFT: MiningDraft = {
assetType: 'stock',
start: yearsAgo(4),
end: TODAY,
profile: 'exploratory',
factorNames: [],
strategyIds: [],
commissionBps: '2',
stampTaxBps: '5',
slippageBps: '5',
correlationThreshold: '0.75',
maxCombinationFactors: '4',
beamWidth: '12',
maxFinalists: '8',
force: false,
}
function loadDraft(): MiningDraft {
try {
const value = JSON.parse(localStorage.getItem(DRAFT_KEY) || '')
if (!value || typeof value !== 'object') return DEFAULT_DRAFT
return { ...DEFAULT_DRAFT, ...value }
} catch {
return DEFAULT_DRAFT
}
}
function parseBoundedNumber(value: string, label: string, min: number, max: number, integer = false) {
const parsed = Number(value)
if (!Number.isFinite(parsed) || parsed < min || parsed > max || (integer && !Number.isInteger(parsed))) {
toast(`${label}需为 ${min}${max}${integer ? ' 的整数' : ''}`, 'error')
return null
}
return parsed
}
function formatNumber(value: number | null | undefined, digits = 2) {
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(digits) : '—'
}
function formatPct(value: number | null | undefined, digits = 2) {
return typeof value === 'number' && Number.isFinite(value) ? `${(value * 100).toFixed(digits)}%` : '—'
}
function formatBytes(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(2)} GB`
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MB`
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`
return `${value} B`
}
function statusLabel(status?: string) {
return ({
queued: '排队中',
running: '挖掘中',
cancelling: '正在取消',
succeeded: '已完成',
succeeded_with_budget_exhausted: '已完成(预算耗尽)',
failed: '失败',
cancelled: '已取消',
interrupted: '启动中断',
skipped_prerequisite: '前置条件不足',
} as Record<string, string>)[status || ''] || '未运行'
}
function confidenceLabel(value?: string) {
return value === 'high' ? '高' : value === 'standard' ? '标准' : '低'
}
function definitionLabel(candidate: MiningCandidateRow) {
if (candidate.kind === 'existing_strategy') return candidate.strategy_id || '已有策略'
return candidate.factor_names?.join(' + ') || '因子组合'
}
function gateTitle(gate?: MiningCandidateGate | null) {
if (!gate || !gate.reasons.length) return undefined
return gate.reasons.join('\n')
}
function RequestSummaryLine({ result }: { result: MiningResult }) {
const request = result.request_summary
if (!request) return null
const items: [string, string][] = [
['资产', request.asset_type === 'etf' ? 'ETF' : '股票'],
['档位', PROFILE_LABELS[request.budget_profile as MiningBudgetProfile] || request.budget_profile],
['区间', `${request.start || '—'}${request.end || '—'}`],
['因子', String(request.factor_count)],
['对照策略', String(request.strategy_count)],
['成本', `佣金 ${formatBps(request.commission_pct)} / 印花 ${formatBps(request.stamp_tax_pct)} / 滑点 ${typeof request.slippage_bps === 'number' && Number.isFinite(request.slippage_bps) ? `${request.slippage_bps.toFixed(1)}bp` : '—'}`],
['相关阈值', request.correlation_threshold == null ? '—' : request.correlation_threshold.toFixed(2)],
]
return (
<div className="flex flex-wrap gap-x-4 gap-y-1 border-b border-border px-3 py-1.5 text-[9px] text-muted">
{items.map(([label, value]) => (
<span key={label} className="min-w-0">
<span className="text-muted">{label}</span>{' '}
<span className="font-mono text-secondary">{value}</span>
</span>
))}
</div>
)
}
function formatBps(ratio: number | null | undefined, digits = 1) {
return typeof ratio === 'number' && Number.isFinite(ratio) ? `${(ratio * 10000).toFixed(digits)}bp` : '—'
}
function foldKindLabel(kind?: string | null) {
if (kind === 'cross') return '跨折'
if (kind === 'benchmark') return '对照'
return undefined
}
function SummaryStrip({ result }: { result: MiningResult }) {
const items = [
['因子', `${result.summary.selected_factor_count}/${result.summary.factor_count}`],
['候选', String(result.summary.candidate_count)],
['有效折', String(result.summary.valid_fold_count)],
['跳过折', String(result.summary.skipped_fold_count)],
['置信度', confidenceLabel(result.summary.confidence)],
['耗时', result.summary.elapsed_ms == null ? '—' : `${(result.summary.elapsed_ms / 1000).toFixed(1)}s`],
['峰值内存', formatBytes(result.summary.peak_rss_bytes)],
]
return (
<div className="grid grid-cols-2 border-b border-border bg-base/30 sm:grid-cols-4 xl:grid-cols-7">
{items.map(([label, value]) => (
<div key={label} className="min-w-0 border-b border-r border-border/60 px-3 py-2 last:border-r-0 sm:border-b-0">
<div className="text-[9px] text-muted">{label}</div>
<div className="mt-0.5 truncate font-mono text-xs font-semibold text-foreground" title={value}>{value}</div>
</div>
))}
</div>
)
}
function RunStatus({ run, progress, error, reconnecting }: { run: MiningRun | null; progress?: { label?: string; phase?: string; percent?: number } | null; error?: string | null; reconnecting?: boolean }) {
const active = !!run && ACTIVE.has(run.status)
const success = !!run && SUCCESS.has(run.status)
const Icon = reconnecting ? RefreshCw : active ? LoaderCircle : success ? CheckCircle2 : error ? AlertTriangle : Clock3
return (
<div className={`flex min-w-0 items-center gap-2 border-b border-border px-3 py-2 ${error ? 'bg-danger/5' : 'bg-base/20'}`}>
<Icon className={`h-3.5 w-3.5 shrink-0 ${reconnecting ? 'animate-spin text-warning' : active ? 'animate-spin text-accent' : success ? 'text-success' : error ? 'text-danger' : 'text-muted'}`} />
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-medium text-foreground">
{reconnecting ? '连接恢复中' : run ? statusLabel(run.status) : '研究任务'}
{progress?.label ? ` · ${progress.label}` : ''}
</div>
{(error || progress?.phase) && (
<div className={`mt-0.5 text-[9px] leading-4 ${error ? 'whitespace-pre-wrap break-words text-danger' : 'truncate text-muted'}`} title={error || progress?.phase}>
{error || progress?.phase}
</div>
)}
</div>
{typeof progress?.percent === 'number' && (
<span className="shrink-0 font-mono text-[10px] text-secondary">{Math.round(progress.percent)}%</span>
)}
{run?.run_id && <span className="hidden max-w-40 truncate font-mono text-[9px] text-muted sm:block">{run.run_id}</span>}
</div>
)
}
function FactorTable({ result }: { result: MiningResult }) {
return (
<div className="min-w-[760px]">
<div className="grid grid-cols-[minmax(160px,1.5fr)_58px_repeat(7,minmax(72px,1fr))_minmax(130px,1.3fr)] border-b border-border bg-base/50 px-2 py-1.5 text-[9px] font-medium text-muted">
<span>因子</span><span>方向</span><span>评分</span><span>IC</span><span>IR</span><span>覆盖</span><span>换手</span><span>价差</span><span>Sharpe</span><span>状态</span>
</div>
{result.factors.map(row => (
<div key={row.factor_name} className="grid grid-cols-[minmax(160px,1.5fr)_58px_repeat(7,minmax(72px,1fr))_minmax(130px,1.3fr)] border-b border-border/50 px-2 py-1.5 text-[10px] text-secondary last:border-b-0 hover:bg-elevated/40">
<span className="truncate font-medium text-foreground" title={row.factor_name}>{row.label || row.factor_name}</span>
<span>{row.direction === 1 ? '正向' : '反向'}</span>
<span className="font-mono">{formatNumber(row.score, 3)}</span>
<span className="font-mono">{formatNumber(row.ic_mean, 4)}</span>
<span className="font-mono">{formatNumber(row.ir, 2)}</span>
<span className="font-mono">{formatPct(row.coverage, 1)}</span>
<span className="font-mono">{formatPct(row.turnover, 1)}</span>
<span className="font-mono">{formatPct(row.spread_return, 2)}</span>
<span className="font-mono">{formatNumber(row.spread_sharpe, 2)}</span>
<span className={row.selected ? 'text-success' : 'truncate text-muted'} title={row.excluded_reason || undefined}>
{row.selected ? '入选' : row.excluded_reason || '未入选'}
</span>
</div>
))}
</div>
)
}
export function MiningWorkbench() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const initializedFactors = useRef(false)
const [draft, setDraft] = useState<MiningDraft>(loadDraft)
const [scheduleDraft, setScheduleDraft] = useState<MiningScheduleConfig | null>(null)
const [correlationScope, setCorrelationScope] = useState<'all' | 'selected'>('selected')
const task = useMiningTask()
// 实时行情开启时盘中 enriched 持续落盘, 排队中的挖掘任务开跑会因数据世代
// 校验失败 (ValueError: mining data generation changed)。开始挖掘前弹窗
// 建议暂时关闭实时; 挂起的 payload 非空即弹窗打开。
const { data: prefs } = usePreferences()
const realtimeToggle = useToggleRealtimeQuotes()
const realtimeOn = !!prefs?.realtime_quotes_enabled
const [pendingMining, setPendingMining] = useState<MiningRequestV1 | null>(null)
const confirmBackdrop = useDialogBackdrop(() => setPendingMining(null), () => !realtimeToggle.isPending)
const submitMining = (payload: MiningRequestV1) => {
const params = new URLSearchParams(searchParams)
params.delete('run')
params.delete('candidate')
setSearchParams(params, { replace: true })
void startMining(payload).then(() => queryClient.invalidateQueries({ queryKey: QK.miningRuns }))
}
const runFromUrl = searchParams.get('run') || ''
const selectedCandidate = searchParams.get('candidate') || ''
const factorQuery = useQuery({ queryKey: QK.factorColumns, queryFn: api.factorColumns })
const strategyQuery = useQuery({
queryKey: QK.strategyLinkOptions(draft.assetType),
queryFn: () => api.strategyList(draft.assetType),
})
const runsQuery = useQuery({
queryKey: QK.miningRuns,
queryFn: api.miningRuns,
refetchInterval: task.isPending ? 5000 : false,
})
const validDateRange = !draft.start || !draft.end || draft.start <= draft.end
const availabilityQuery = useQuery({
queryKey: QK.miningAvailability(
draft.assetType,
draft.profile,
draft.start,
draft.end,
),
queryFn: () => api.miningAvailability({
assetType: draft.assetType,
budgetProfile: draft.profile,
start: draft.start || undefined,
end: draft.end || undefined,
}),
enabled: validDateRange,
staleTime: 30_000,
})
const regimeLatestQuery = useQuery({ queryKey: QK.regimeLatest, queryFn: api.regimeLatest, staleTime: 60_000 })
const configQuery = useQuery({ queryKey: QK.miningConfig, queryFn: api.miningConfig })
useEffect(() => {
localStorage.setItem(DRAFT_KEY, JSON.stringify(draft))
}, [draft])
useEffect(() => {
if (initializedFactors.current || !factorQuery.data?.columns.length) return
initializedFactors.current = true
if (!draft.factorNames.length) {
setDraft(current => ({ ...current, factorNames: factorQuery.data!.columns.slice(0, 48).map(item => item.id) }))
}
}, [factorQuery.data, draft.factorNames.length])
useEffect(() => {
if (configQuery.data && !scheduleDraft) setScheduleDraft(configQuery.data)
}, [configQuery.data, scheduleDraft])
useEffect(() => {
if (runFromUrl) {
if (task.isPending && !task.runId) return
if (task.runId !== runFromUrl) void attachMiningRun(runFromUrl)
return
}
if (task.runId) {
const params = new URLSearchParams(searchParams)
params.set('run', task.runId)
setSearchParams(params, { replace: true })
return
}
tryReconnectMining()
}, [runFromUrl, searchParams, setSearchParams, task.runId])
const currentResult = task.runId
&& (!runFromUrl || runFromUrl === task.runId)
&& task.run?.run_id === task.runId
&& SUCCESS.has(task.run.status)
&& task.result?.run_id === task.runId
? task.result
: null
const showingPrevious = !!(
task.runId
&& (!runFromUrl || runFromUrl === task.runId)
&& task.previousResult
&& task.previousResult.run_id !== task.runId
&& (
task.isPending
|| !!task.error
|| (!!task.run && !SUCCESS.has(task.run.status))
)
)
const result = currentResult ?? (showingPrevious ? task.previousResult : null)
const candidates = result?.candidates ?? []
const activeCandidate = candidates.find(item => item.signature === selectedCandidate) ?? candidates[0] ?? null
const candidateFolds = activeCandidate?.folds?.length ? activeCandidate.folds : result?.folds ?? []
const correlation = useMemo(() => {
if (!result || correlationScope === 'all') return result?.correlation ?? null
const selected = new Set(result.factors.filter(item => item.selected).map(item => item.factor_name))
const indexes = result.correlation.labels
.map((label, index) => selected.has(label) ? index : -1)
.filter(index => index >= 0)
if (!indexes.length) return result.correlation
return {
...result.correlation,
labels: indexes.map(index => result.correlation.labels[index]),
matrix: indexes.map(row => indexes.map(column => result.correlation.matrix[row]?.[column] ?? null)),
pair_counts: indexes.map(row => indexes.map(column => result.correlation.pair_counts?.[row]?.[column] ?? null)),
}
}, [result, correlationScope])
const setSelectedCandidate = (signature: string) => {
const params = new URLSearchParams(searchParams)
if (signature) params.set('candidate', signature)
else params.delete('candidate')
setSearchParams(params, { replace: true })
}
useEffect(() => {
if (!selectedCandidate && candidates[0]) setSelectedCandidate(candidates[0].signature)
if (selectedCandidate && candidates.length && !candidates.some(item => item.signature === selectedCandidate)) {
setSelectedCandidate(candidates[0]?.signature || '')
}
}, [candidates, selectedCandidate])
const factorGroups = useMemo(() => {
const groups: Record<string, FactorColumn[]> = {}
for (const item of factorQuery.data?.columns ?? []) (groups[item.group] ??= []).push(item)
return groups
}, [factorQuery.data])
const strategies = strategyQuery.data?.strategies.filter(item => item.execution_backend === 'matrix_native') ?? []
useEffect(() => {
if (!strategyQuery.data) return
const compatibleIds = new Set(strategies.map(item => item.id))
setDraft(current => {
const strategyIds = current.strategyIds.filter(id => compatibleIds.has(id))
return strategyIds.length === current.strategyIds.length ? current : { ...current, strategyIds }
})
}, [strategyQuery.data])
const updateDraft = <K extends keyof MiningDraft>(key: K, value: MiningDraft[K]) => {
setDraft(current => ({ ...current, [key]: value }))
}
const changeAssetType = (assetType: MiningDraft['assetType']) => {
setDraft(current => ({
...current,
assetType,
strategyIds: [],
}))
}
const toggleFactor = (id: string) => {
setDraft(current => ({
...current,
factorNames: current.factorNames.includes(id)
? current.factorNames.filter(value => value !== id)
: current.factorNames.length < 48 ? [...current.factorNames, id] : current.factorNames,
}))
}
const toggleStrategy = (id: string) => {
setDraft(current => ({
...current,
strategyIds: current.strategyIds.includes(id)
? current.strategyIds.filter(value => value !== id)
: current.strategyIds.length < 8 ? [...current.strategyIds, id] : current.strategyIds,
}))
}
const runMining = () => {
if (!draft.factorNames.length) {
toast('至少选择一个因子', 'error')
return
}
if (draft.start && draft.end && draft.start > draft.end) {
toast('开始日期不能晚于结束日期', 'error')
return
}
if (availabilityQuery.isPending || availabilityQuery.isFetching) {
toast('正在核验有效交易日,请稍候', 'error')
return
}
if (availabilityQuery.isError || !availabilityQuery.data) {
toast('无法核验有效交易日,请检查数据状态后重试', 'error')
return
}
if (!availabilityQuery.data.eligible) {
toast(
`${PROFILE_LABELS[draft.profile]}至少需要 ${availabilityQuery.data.required_bars} 个交易日,当前范围仅 ${availabilityQuery.data.trading_bars} 个`,
'error',
)
return
}
if (regimeLatestQuery.isPending || regimeLatestQuery.isFetching) {
toast('正在核验市场环境数据,请稍候', 'error')
return
}
if (regimeLatestQuery.isError || !regimeLatestQuery.data) {
toast('无法核验市场环境数据,请检查数据状态后重试', 'error')
return
}
if (!regimeLatestQuery.data.row) {
toast('尚未计算市场环境数据:挖掘含市场环境分组评估,请先在数据页完成市场环境计算', 'error')
return
}
const commissionBps = parseBoundedNumber(draft.commissionBps, '佣金', 0, 500)
const stampTaxBps = parseBoundedNumber(draft.stampTaxBps, '印花税', 0, 500)
const slippageBps = parseBoundedNumber(draft.slippageBps, '滑点', 0, 1000)
const correlationThreshold = parseBoundedNumber(draft.correlationThreshold, '相关阈值', Number.EPSILON, 1)
const maxCombinationFactors = parseBoundedNumber(draft.maxCombinationFactors, '组合上限', 1, 4, true)
const beamWidth = parseBoundedNumber(draft.beamWidth, 'Beam', 1, 12, true)
const maxFinalists = parseBoundedNumber(draft.maxFinalists, 'Finalists', 1, 8, true)
if ([commissionBps, stampTaxBps, slippageBps, correlationThreshold, maxCombinationFactors, beamWidth, maxFinalists].some(value => value == null)) return
const payload: MiningRequestV1 = {
factor_names: draft.factorNames,
strategy_ids: draft.strategyIds,
asset_type: draft.assetType,
start: draft.start || null,
end: draft.end || null,
budget_profile: draft.profile,
commission_pct: commissionBps! / 10000,
stamp_tax_pct: stampTaxBps! / 10000,
slippage_bps: slippageBps!,
correlation_threshold: correlationThreshold!,
max_combination_factors: maxCombinationFactors!,
beam_width: beamWidth!,
max_finalists: maxFinalists!,
force: draft.force,
}
if (realtimeOn) {
setPendingMining(payload)
return
}
submitMining(payload)
}
const promote = useMutation({
mutationFn: (candidate: MiningCandidateRow) => {
if (!currentResult) throw new Error('只能保存当前已成功运行的候选')
return api.miningPromote(currentResult.run_id, candidate.signature)
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
toast('候选已保存,状态为待验证', 'success')
},
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
})
const publish = useMutation({
mutationFn: (candidate: MiningCandidateRow) => {
if (!currentResult) throw new Error('只能发布当前已成功运行的候选')
return api.miningPublish(currentResult.run_id, candidate.signature)
},
onSuccess: value => {
queryClient.invalidateQueries({ queryKey: QK.screenerStrategies(draft.assetType) })
toast(`策略已发布 · ${value.strategy_id}`, 'success')
},
onError: error => toast(`发布失败 · ${String((error as Error).message || error)}`, 'error'),
})
const saveSchedule = useMutation({
mutationFn: (value: MiningScheduleConfig) => api.updateMiningConfig(value),
onSuccess: value => {
setScheduleDraft(value)
queryClient.setQueryData(QK.miningConfig, value)
toast('自动挖掘配置已保存', 'success')
},
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
})
const attachRun = (run: MiningRun) => {
const params = new URLSearchParams(searchParams)
params.set('run', run.run_id)
params.delete('candidate')
setSearchParams(params, { replace: true })
}
return (
<div className="grid min-h-[calc(100vh-9rem)] grid-cols-1 overflow-hidden rounded-card border border-border bg-surface xl:grid-cols-[20rem_minmax(0,1fr)]">
<aside className="border-b border-border bg-base/25 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto xl:border-b-0 xl:border-r">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<div>
<div className="text-xs font-semibold text-foreground">挖掘配置</div>
<div className="mt-0.5 text-[9px] text-muted">日频 · 嵌套样本外 · T-1 环境</div>
</div>
<span className="font-mono text-[9px] text-muted">{draft.factorNames.length}/48</span>
</div>
<div className="space-y-4 p-3">
<section>
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold text-secondary"><Database className="h-3 w-3" />数据范围</div>
<div className="grid grid-cols-2 gap-2">
<label><span className={LABEL}>资产</span><select className={INPUT} value={draft.assetType} onChange={event => changeAssetType(event.target.value as 'stock' | 'etf')}><option value="stock">股票</option><option value="etf">ETF</option></select></label>
<label><span className={LABEL}>置信档</span><select className={INPUT} value={draft.profile} onChange={event => updateDraft('profile', event.target.value as MiningBudgetProfile)}><option value="exploratory">探索 · 219 </option><option value="balanced">均衡 · 786 </option><option value="strict">严格 · 1164 </option></select></label>
<label><span className={LABEL}>开始</span><input type="date" className={INPUT} value={draft.start} onChange={event => updateDraft('start', event.target.value)} /></label>
<label><span className={LABEL}>结束</span><input type="date" className={INPUT} value={draft.end} onChange={event => updateDraft('end', event.target.value)} /></label>
</div>
{!validDateRange ? (
<div className="mt-1.5 text-[9px] leading-4 text-danger">开始日期不能晚于结束日期。</div>
) : availabilityQuery.isPending || availabilityQuery.isFetching ? (
<div className="mt-1.5 flex items-center gap-1 text-[9px] leading-4 text-muted"><LoaderCircle className="h-3 w-3 animate-spin" />正在核验有效交易日…</div>
) : availabilityQuery.isError || !availabilityQuery.data ? (
<div className="mt-1.5 text-[9px] leading-4 text-danger">无法核验 enriched 交易日,暂不能开始挖掘。</div>
) : (
<div className={`mt-1.5 text-[9px] leading-4 ${availabilityQuery.data.eligible ? 'text-secondary' : 'text-warning'}`}>
<div>
当前范围 {availabilityQuery.data.trading_bars} 个交易日;{PROFILE_LABELS[draft.profile]}至少需要 {availabilityQuery.data.required_bars} 个,可生成 {availabilityQuery.data.outer_folds}/{availabilityQuery.data.required_outer_folds} outer folds
</div>
{!availabilityQuery.data.eligible && availabilityQuery.data.suggested_start && draft.start !== availabilityQuery.data.suggested_start && (
<button type="button" className="mt-0.5 text-left text-accent hover:underline" onClick={() => updateDraft('start', availabilityQuery.data!.suggested_start!)}>
使用建议开始日 {availabilityQuery.data.suggested_start}
</button>
)}
{!availabilityQuery.data.eligible && !availabilityQuery.data.suggested_start && draft.profile !== 'exploratory' && (
<div>本地历史数据不足;可改用探索档,系统不会自动降档。</div>
)}
</div>
)}
</section>
{regimeLatestQuery.data && !regimeLatestQuery.data.row && (
<Link to="/data" className="block rounded-btn border border-warning/40 bg-warning/5 px-2 py-1.5 text-[9px] leading-4 text-warning transition-colors hover:border-warning/70">
尚未计算市场环境数据 挖掘含市场环境分组评估,缺少时启动即校验失败。<span className="underline underline-offset-2">前往数据页完成市场环境计算 </span>
</Link>
)}
<section className="border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[10px] font-semibold text-secondary"><FlaskConical className="h-3 w-3" />因子目录</span>
<button type="button" className="text-[9px] text-accent" onClick={() => updateDraft('factorNames', draft.factorNames.length ? [] : (factorQuery.data?.columns ?? []).slice(0, 48).map(item => item.id))}>{draft.factorNames.length ? '清空' : '全选'}</button>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto pr-1">
{Object.entries(factorGroups).map(([group, items]) => (
<div key={group}>
<div className="mb-1 text-[9px] font-medium text-muted">{group}</div>
<div className="grid grid-cols-2 gap-1">
{items.map(item => <label key={item.id} className="flex min-w-0 cursor-pointer items-center gap-1.5 text-[9px] text-secondary"><input type="checkbox" className="h-3 w-3 accent-accent" checked={draft.factorNames.includes(item.id)} onChange={() => toggleFactor(item.id)} /><span className="truncate" title={`${item.label} · ${item.desc}`}>{item.label}</span></label>)}
</div>
</div>
))}
{factorQuery.isLoading && <div className="text-[10px] text-muted">加载因子目录…</div>}
{factorQuery.isError && <div className="text-[10px] text-danger">因子目录加载失败</div>}
</div>
</section>
<section className="border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between text-[10px] font-semibold text-secondary"><span>已有策略对照</span><span className="font-mono text-[9px] text-muted">{draft.strategyIds.length}/8</span></div>
<div className="max-h-32 space-y-1 overflow-y-auto pr-1">
{strategies.map(item => <label key={item.id} className="flex min-w-0 cursor-pointer items-center gap-1.5 text-[9px] text-secondary"><input type="checkbox" className="h-3 w-3 accent-accent" checked={draft.strategyIds.includes(item.id)} onChange={() => toggleStrategy(item.id)} /><span className="truncate" title={item.description}>{item.name}</span></label>)}
{strategyQuery.isError && <div className="text-[9px] text-danger">策略目录加载失败</div>}
{!strategyQuery.isLoading && !strategyQuery.isError && !strategies.length && <div className="text-[9px] text-muted">无可用 matrix-native 策略</div>}
</div>
</section>
<section className="border-t border-border pt-3">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold text-secondary"><Settings2 className="h-3 w-3" />成本与预算</div>
<div className="grid grid-cols-3 gap-2">
<label><span className={LABEL}>佣金 bp</span><input className={INPUT} inputMode="decimal" value={draft.commissionBps} onChange={event => updateDraft('commissionBps', event.target.value)} /></label>
<label><span className={LABEL}>印花税 bp</span><input className={INPUT} inputMode="decimal" value={draft.stampTaxBps} onChange={event => updateDraft('stampTaxBps', event.target.value)} /></label>
<label><span className={LABEL}>滑点 bp</span><input className={INPUT} inputMode="decimal" value={draft.slippageBps} onChange={event => updateDraft('slippageBps', event.target.value)} /></label>
<label><span className={LABEL}>相关阈值</span><input className={INPUT} inputMode="decimal" value={draft.correlationThreshold} onChange={event => updateDraft('correlationThreshold', event.target.value)} /></label>
<label><span className={LABEL}>组合上限</span><input className={INPUT} inputMode="numeric" value={draft.maxCombinationFactors} onChange={event => updateDraft('maxCombinationFactors', event.target.value)} /></label>
<label><span className={LABEL}>Beam</span><input className={INPUT} inputMode="numeric" value={draft.beamWidth} onChange={event => updateDraft('beamWidth', event.target.value)} /></label>
<label><span className={LABEL}>Finalists</span><input className={INPUT} inputMode="numeric" value={draft.maxFinalists} onChange={event => updateDraft('maxFinalists', event.target.value)} /></label>
<label className="col-span-2 flex items-end gap-1.5 pb-1 text-[9px] text-secondary"><input type="checkbox" className="h-3 w-3 accent-accent" checked={draft.force} onChange={event => updateDraft('force', event.target.checked)} />忽略同配置缓存</label>
</div>
</section>
<div className="sticky bottom-0 flex gap-2 bg-base/95 py-2">
<button type="button" disabled={task.isPending || !draft.factorNames.length || (draft.strategyIds.length > 0 && strategyQuery.isLoading) || !validDateRange || availabilityQuery.isPending || availabilityQuery.isFetching || availabilityQuery.isError || !availabilityQuery.data?.eligible} onClick={runMining} className="inline-flex h-8 flex-1 items-center justify-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-50"><Play className="h-3.5 w-3.5" />开始挖掘</button>
{task.isPending && <button type="button" title="取消任务" disabled={task.cancelling} onClick={() => void cancelMining()} className="inline-flex h-8 w-9 items-center justify-center rounded-btn border border-danger/40 text-danger hover:bg-danger/10 disabled:opacity-50"><Square className="h-3.5 w-3.5" /></button>}
</div>
<section className="border-t border-border pt-3">
<div className="mb-2 flex items-center justify-between"><span className="text-[10px] font-semibold text-secondary">最近运行</span><button type="button" title="刷新历史" onClick={() => void runsQuery.refetch()} className="text-muted hover:text-accent"><RefreshCw className={`h-3 w-3 ${runsQuery.isFetching ? 'animate-spin' : ''}`} /></button></div>
<div className="max-h-40 space-y-1 overflow-y-auto">
{(runsQuery.data?.items ?? []).map(run => <button key={run.run_id} type="button" onClick={() => attachRun(run)} className={`flex w-full items-center gap-2 rounded-btn px-2 py-1.5 text-left hover:bg-elevated ${task.runId === run.run_id ? 'bg-accent/10' : ''}`}><span className={`h-1.5 w-1.5 shrink-0 rounded-full ${SUCCESS.has(run.status) ? 'bg-success' : ACTIVE.has(run.status) ? 'bg-accent' : 'bg-muted'}`} /><span className="min-w-0 flex-1 truncate font-mono text-[9px] text-secondary">{run.run_id}</span><span className="shrink-0 text-[9px] text-muted">{statusLabel(run.status)}</span></button>)}
{runsQuery.isError && <div className="text-[9px] text-danger">运行历史加载失败</div>}
{!runsQuery.isLoading && !runsQuery.isError && !(runsQuery.data?.items.length) && <div className="text-[9px] text-muted">暂无持久运行</div>}
</div>
</section>
{scheduleDraft && <section className="border-t border-border pt-3"><div className="mb-2 text-[10px] font-semibold text-secondary">周度自动挖掘</div><label className="mb-2 flex items-center gap-1.5 text-[9px] text-secondary"><input type="checkbox" className="h-3 w-3 accent-accent" checked={scheduleDraft.mining_schedule_enabled} onChange={event => setScheduleDraft({ ...scheduleDraft, mining_schedule_enabled: event.target.checked })} />启用(默认关闭,不自动发布)</label><div className="grid grid-cols-2 gap-2"><label><span className={LABEL}>工作日</span><select className={INPUT} value={scheduleDraft.mining_schedule_weekday} onChange={event => setScheduleDraft({ ...scheduleDraft, mining_schedule_weekday: Number(event.target.value) })}>{['周一', '周二', '周三', '周四', '周五'].map((label, index) => <option key={label} value={index}>{label}</option>)}</select></label><label><span className={LABEL}>档位</span><select className={INPUT} value={scheduleDraft.mining_budget_profile} onChange={event => setScheduleDraft({ ...scheduleDraft, mining_budget_profile: event.target.value as 'balanced' | 'strict' })}><option value="balanced">均衡</option><option value="strict">严格</option></select></label></div><button type="button" disabled={saveSchedule.isPending} onClick={() => saveSchedule.mutate(scheduleDraft)} className="mt-2 inline-flex h-7 w-full items-center justify-center gap-1 rounded-btn border border-border text-[10px] text-secondary hover:border-accent/40 hover:text-accent disabled:opacity-50"><Save className="h-3 w-3" />保存自动配置</button></section>}
</div>
</aside>
<section className="min-w-0 bg-surface xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto">
<RunStatus run={task.run} progress={task.progress} error={task.error} reconnecting={task.reconnecting} />
{showingPrevious && <div className="border-b border-warning/30 bg-warning/5 px-3 py-1.5 text-[10px] text-warning">历史结果 · run {result?.run_id}。当前 run {task.runId} {task.isPending ? '仍在执行' : '未成功完成'},以下内容仅供参考,候选操作已禁用。</div>}
{!result ? (
<div className="min-h-[32rem]"><EmptyState icon={FlaskConical} title={task.isPending ? '挖掘任务正在执行' : '尚无挖掘结果'} hint={task.isPending ? '任务在独立 worker 中运行;可切换页面或刷新后按 run ID 重连。' : '选择因子和验证档位后开始。探索档结果仅用于研究,不代表已验证策略。'} /></div>
) : (
<div className="min-w-0">
<SummaryStrip result={result} />
<RequestSummaryLine result={result} />
<section className="border-b border-border">
<div className="flex items-center justify-between px-3 py-2"><h2 className="text-xs font-semibold text-foreground">因子排名</h2><span className="text-[9px] text-muted">{result.methodology_version}</span></div>
<div className="overflow-x-auto border-t border-border"><FactorTable result={result} /></div>
</section>
<div className="grid grid-cols-1 border-b border-border 2xl:grid-cols-2">
<section className="min-w-0 border-b border-border 2xl:border-b-0 2xl:border-r">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
<h2 className="shrink-0 text-xs font-semibold text-foreground">市场环境对比</h2>
{activeCandidate && candidates[0] && activeCandidate.signature !== candidates[0].signature && <span className="truncate text-[9px] text-warning">环境数据属于排名首位候选({candidates[0].name}</span>}
</div>
<RegimeComparisonChart rows={result.regimes.map(row => ({ state: row.state, label: row.label, nDates: row.n_dates, sharpe: row.sharpe, return: row.total_return, maxDrawdown: row.max_drawdown }))} />
</section>
<section className="min-w-0"><h2 className="border-b border-border px-3 py-2 text-xs font-semibold text-foreground">逐折样本外</h2><MiningOosChart folds={candidateFolds.map(row => ({ fold: row.fold, label: row.label || `Fold ${row.fold}`, return: row.total_return, sharpe: row.sharpe, skipped: row.skipped, reason: row.reason || undefined }))} /></section>
</div>
<section className="border-b border-border">
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<h2 className="text-xs font-semibold text-foreground">相关矩阵</h2>
<div className="mt-0.5 text-[9px] text-muted">阈值 {result.correlation.threshold.toFixed(2)} · 按日截面 Rank</div>
</div>
<div className="inline-flex shrink-0 rounded-btn border border-border bg-base p-0.5" aria-label="相关矩阵范围">
{(['selected', 'all'] as const).map(scope => (
<button
key={scope}
type="button"
onClick={() => setCorrelationScope(scope)}
className={`h-6 rounded-[4px] px-2 text-[9px] ${correlationScope === scope ? 'bg-accent text-white' : 'text-secondary hover:bg-elevated'}`}
>
{scope === 'selected' ? '入选' : '全部'}
</button>
))}
</div>
</div>
<div className="overflow-x-auto border-t border-border">
<div style={{ minWidth: `${Math.max(360, (correlation?.labels.length ?? 0) * 48 + 140)}px` }}>
<FactorCorrelationHeatmap
labels={correlation?.labels ?? []}
matrix={correlation?.matrix ?? []}
pairCounts={correlation?.pair_counts}
threshold={result.correlation.threshold}
/>
</div>
</div>
</section>
<section className="border-b border-border"><div className="flex items-center justify-between gap-2 px-3 py-2"><h2 className="shrink-0 text-xs font-semibold text-foreground">策略候选</h2><span className="truncate text-[9px] text-muted">对照策略在全部 outer 折独立评估 · 发布始终需要人工确认</span></div><div className="grid min-h-72 grid-cols-1 border-t border-border lg:grid-cols-[18rem_minmax(0,1fr)]"><div className="border-b border-border lg:border-b-0 lg:border-r">{candidates.map(candidate => <button key={candidate.signature} type="button" onClick={() => setSelectedCandidate(candidate.signature)} className={`block w-full border-b border-border/60 px-3 py-2 text-left hover:bg-elevated ${activeCandidate?.signature === candidate.signature ? 'bg-accent/10' : ''}`}><div className="flex items-center justify-between gap-2"><span className="truncate text-[10px] font-medium text-foreground">{candidate.name}</span><span className="font-mono text-[9px] text-muted">{formatNumber(candidate.score, 2)}</span></div><div className="mt-1 flex items-center gap-1.5"><span className="min-w-0 truncate text-[9px] text-muted" title={definitionLabel(candidate)}>{definitionLabel(candidate)}</span>{candidate.gate && <span className={`shrink-0 rounded-full px-1.5 py-px text-[8px] font-medium ${candidate.gate.qualified ? 'bg-success/10 text-success' : 'bg-warning/10 text-warning'}`} title={gateTitle(candidate.gate)}>{candidate.gate.qualified ? '达标' : '未达标'}</span>}</div></button>)}{!candidates.length && <div className="p-4 text-[10px] text-muted">暂无晋级候选</div>}</div>{activeCandidate ? <div className="min-w-0 p-3"><div className="flex flex-wrap items-start justify-between gap-3"><div className="min-w-0"><div className="flex items-center gap-2"><span className="truncate text-xs font-semibold text-foreground">{activeCandidate.name}</span>{activeCandidate.gate && <span className={`shrink-0 rounded-full px-1.5 py-px text-[9px] font-medium ${activeCandidate.gate.qualified ? 'bg-success/10 text-success' : 'bg-warning/10 text-warning'}`} title={gateTitle(activeCandidate.gate)}>{activeCandidate.gate.qualified ? '达标' : '未达标'}</span>}</div><div className="mt-1 break-words font-mono text-[9px] text-muted">{definitionLabel(activeCandidate)}</div></div><div className="flex gap-2"><button type="button" disabled={!currentResult || promote.isPending} onClick={() => promote.mutate(activeCandidate)} className="inline-flex h-7 items-center gap-1 rounded-btn border border-border px-2 text-[10px] text-secondary hover:border-accent/40 hover:text-accent disabled:opacity-50"><Save className="h-3 w-3" />保存候选</button><button type="button" disabled={!currentResult || publish.isPending || activeCandidate.gate?.qualified === false} title={gateTitle(activeCandidate.gate)} onClick={() => { if (window.confirm(`确认发布“${activeCandidate.name}”?\n\n发布会创建独立策略,但不会自动用于实盘或监控。`)) publish.mutate(activeCandidate) }} className="inline-flex h-7 items-center gap-1 rounded-btn bg-accent px-2 text-[10px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"><Rocket className="h-3 w-3" />显式发布</button></div></div>{activeCandidate.gate && !activeCandidate.gate.qualified && <div className="mt-2 rounded-btn border border-warning/30 bg-warning/5 px-2 py-1.5 text-[9px] leading-4 text-warning">未达晋级门槛,仅可保存为待定候选:{activeCandidate.gate.reasons.join('')}</div>}<div className="mt-4 grid grid-cols-2 gap-px border border-border bg-border sm:grid-cols-6">{[['平均每折收益', formatPct(activeCandidate.oos_return)], ['Sharpe', formatNumber(activeCandidate.oos_sharpe)], ['最大回撤', formatPct(activeCandidate.oos_max_drawdown)], ['正收益折', formatPct(activeCandidate.oos_positive_fold_ratio)], ['有效折', activeCandidate.valid_folds == null ? '—' : String(activeCandidate.valid_folds)], ['交易数', activeCandidate.oos_n_trades == null ? '—' : String(activeCandidate.oos_n_trades)]].map(([label, value]) => <div key={label} className="bg-surface px-2 py-2"><div className="text-[9px] text-muted">{label}</div><div className="mt-1 font-mono text-[11px] font-semibold text-foreground">{value}</div></div>)}</div><div className="mt-3 overflow-x-auto"><div className="min-w-[650px]">{candidateFolds.map(fold => <div key={`${fold.fold}-${fold.evaluation_kind || 'selected'}-${fold.test_start}`} className="grid grid-cols-[60px_1fr_1fr_repeat(4,80px)] border-b border-border/60 py-1.5 text-[9px] text-secondary"><span className="flex items-center gap-1"><span>Fold {fold.fold}</span>{foldKindLabel(fold.evaluation_kind) && <span className="rounded-full bg-elevated px-1 py-px text-[8px] text-muted">{foldKindLabel(fold.evaluation_kind)}</span>}</span><span>{fold.train_start || '—'} {fold.train_end || '—'}</span><span>{fold.test_start || '—'} {fold.test_end || '—'}</span><span className="font-mono">{formatPct(fold.total_return)}</span><span className="font-mono">{formatNumber(fold.sharpe)}</span><span className="font-mono">{formatPct(fold.max_drawdown)}</span><span className={fold.skipped ? 'text-warning' : 'text-success'}>{fold.skipped ? fold.reason || '跳过' : `${fold.n_trades ?? '—'} 笔`}</span></div>)}</div></div></div> : <div className="grid place-items-center p-8 text-xs text-muted">选择候选查看定义与逐折结果</div>}</div></section>
<section><div className="flex items-center gap-1.5 border-b border-border px-3 py-2"><Gauge className="h-3.5 w-3.5 text-muted" /><h2 className="text-xs font-semibold text-foreground">性能与复用</h2></div><div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4 lg:grid-cols-7">{[['总耗时', result.telemetry.elapsed_ms == null ? '—' : `${(result.telemetry.elapsed_ms / 1000).toFixed(1)}s`], ['峰值 RSS', formatBytes(result.telemetry.peak_rss_bytes)], ['面板扫描', result.telemetry.panel_scans ?? '—'], ['矩阵字节', formatBytes(result.telemetry.matrix_bytes)], ['缓存命中', result.telemetry.cache_hits ?? '—'], ['Fold 复用', result.telemetry.fold_reuses ?? '—'], ['IPC 结果', formatBytes(result.telemetry.serialized_result_bytes)]].map(([label, value]) => <div key={label} className="bg-surface px-3 py-2"><div className="text-[9px] text-muted">{label}</div><div className="mt-1 font-mono text-[11px] font-semibold text-foreground">{String(value)}</div></div>)}</div>{result.telemetry.phase_ms && <div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-3 py-2">{Object.entries(result.telemetry.phase_ms).map(([phase, ms]) => <span key={phase} className="text-[9px] text-muted"><span>{phase}</span> <span className="font-mono text-secondary">{ms.toFixed(1)}ms</span></span>)}</div>}</section>
</div>
)}
{task.runId && <div className="flex items-center gap-1.5 border-t border-border px-3 py-2 text-[9px] text-muted"><Link2 className="h-3 w-3" />刷新后通过持久 run ID 自动重连;浏览器断开不会取消 worker</div>}
</section>
{/* 实时行情开启时的挖掘确认: 盘中 enriched 持续落盘会让排队任务开跑即
失败 (data generation changed), 建议先关实时再开始。 */}
{pendingMining && (
<div {...confirmBackdrop} className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div onClick={e => e.stopPropagation()} className="w-full max-w-sm rounded-2xl border border-border bg-surface p-5 shadow-2xl">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
实时行情已开启
</div>
<p className="mt-2 text-xs leading-5 text-secondary">
实时行情开启期间,盘中数据会持续落盘,挖掘任务可能在启动时因数据更新校验而失败(提示 mining data generation changed)。
建议先暂时关闭实时行情,任务启动后再重新开启。
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={realtimeToggle.isPending} onClick={() => setPendingMining(null)} className="h-8 rounded-btn border border-border px-3 text-xs text-secondary hover:bg-elevated disabled:opacity-50">取消</button>
<button type="button" disabled={realtimeToggle.isPending} onClick={() => { const p = pendingMining; setPendingMining(null); if (p) submitMining(p) }} className="h-8 rounded-btn border border-border px-3 text-xs text-foreground hover:bg-elevated disabled:opacity-50">仍要开始</button>
<button type="button" disabled={realtimeToggle.isPending} onClick={() => { const p = pendingMining; if (!p) return; void realtimeToggle.mutateAsync(false).then(() => { setPendingMining(null); submitMining(p) }) }} className="inline-flex h-8 items-center gap-1.5 rounded-btn bg-accent px-3 text-xs font-semibold text-white hover:opacity-90 disabled:opacity-50">
{realtimeToggle.isPending ? <LoaderCircle className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
关闭实时并开始
</button>
</div>
</div>
</div>
)}
</div>
)
}