import { useState, useMemo } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { motion } from 'framer-motion'
import { Play, BarChart3, Clock } from 'lucide-react'
import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
import { fmtPct, priceColorClass } from '@/lib/format'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { FactorICChart } from './charts/FactorICChart'
import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
const monthsAgo = (months: number) => {
const date = new Date()
date.setMonth(date.getMonth() - months)
return formatDate(date)
}
const TODAY = formatDate(new Date())
const THREE_MONTHS_AGO = monthsAgo(3)
const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
function StatCard({ label, value, highlight }: {
label: string
value: string | null | undefined
highlight?: 'bull' | 'bear' | 'neutral'
}) {
const colorCls = highlight === 'bull'
? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
return (
)
}
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
return (
正在计算因子分析
{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
))}
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
))}
)
}
export function FactorBacktest() {
const [factorName, setFactorName] = useState('momentum_20d')
const [symbols, setSymbols] = useState('')
const [start, setStart] = useState(THREE_MONTHS_AGO)
const [end, setEnd] = useState(TODAY)
const [nGroups, setNGroups] = useState(5)
const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
const [fees, setFees] = useState('2')
const [result, setResult] = useState(null)
const columns = useQuery({
queryKey: ['backtest-factor-columns'],
queryFn: api.factorColumns,
})
// 按 group 分类的因子
const factorGroups = useMemo(() => {
const cols = columns.data?.columns ?? []
const groups: Record = {}
for (const c of cols) {
;(groups[c.group] ??= []).push(c)
}
return groups
}, [columns.data])
// 当前因子描述
const factorDesc = useMemo(() => {
return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
}, [columns.data, factorName])
const run = useMutation({
mutationFn: () =>
api.factorRun({
factor_name: factorName,
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
start: start || null,
end: end || undefined,
n_groups: nGroups,
rebalance: 'daily',
weight,
fees_pct: Number(fees) / 10000,
}),
onSuccess: (data) => {
if (data.error) {
setResult(data)
} else {
setResult(data)
}
},
})
const applyRange = (months: number) => {
setStart(monthsAgo(months))
setEnd(formatDate(new Date()))
}
const applyAllRange = () => {
setStart('')
setEnd(formatDate(new Date()))
}
const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
? '3m'
: end === TODAY && start === monthsAgo(6)
? '6m'
: end === TODAY && start === monthsAgo(12)
? '1y'
: end === TODAY && start === ''
? 'all'
: 'custom'
const rangeTitle = rangeKey === '3m'
? '近 3 个月'
: rangeKey === '6m'
? '近 6 个月'
: rangeKey === '1y'
? '近 1 年'
: rangeKey === 'all'
? '全部历史'
: '自定义区间'
const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
? 'bg-accent/15 text-accent'
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
}`
return (
{/* 配置面板 */}
因子配置
选择因子、区间和分组方式。默认最近 3 个月。
{factorDesc && (
{factorDesc}
)}
setSymbols(e.target.value)}
placeholder="留空则使用全市场,建议最近3个月"
className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
/>
setFees(e.target.value)}
className={INPUT_CLS} />
{/* 结果面板 */}
{result?.error && !result.ic_mean && (
{result.error}
)}
{run.isError && (
{String((run.error as any).message)}
)}
{!result && !run.isPending && (
)}
{run.isPending && result && (
正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。
)}
{run.isPending && !result && (
)}
{result && result.ic_mean != null && (
{/* IC/IR 指标 */}
因子预测能力
Rank IC · 日度调仓
{result.elapsed_ms > 0 && (
{result.elapsed_ms.toFixed(0)} ms
)}
0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
: undefined}
/>
0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
: undefined}
/>
{/* IC 时序图 */}
{result.ic_series.length > 0 && (
)}
{/* 分层净值 */}
{result.group_nav.length > 0 && (
)}
{/* 分层统计表 */}
{result.group_stats.length > 0 && (
| 分组 |
总收益 |
年化 |
最大回撤 |
夏普 |
胜率 |
{result.group_stats.map((g: GroupStat) => (
| {g.label} |
{fmtPct(g.total_return)}
|
{fmtPct(g.annual_return)}
|
{fmtPct(g.max_drawdown)} |
{g.sharpe?.toFixed(2)} |
{fmtPct(g.win_rate)} |
))}
{/* 多空行 */}
{result.long_short_stats?.total_return != null && (
|
多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
|
{fmtPct(result.long_short_stats.total_return as number)}
|
— |
{fmtPct(result.long_short_stats.max_drawdown as number)}
|
— |
— |
)}
)}
{/* 数据概要 */}
{result.n_symbols} 只标的
{result.n_dates} 个交易日
run_id: {result.run_id}
)}
)
}