项目目录从 refer 迁移到 local

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-04 16:32:48 +08:00
parent 648a8b7f1c
commit aad34202f1
302 changed files with 0 additions and 0 deletions
@@ -0,0 +1,447 @@
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 (
<div>
<div className="text-[11px] text-muted">{label}</div>
<div className={`mt-1 text-lg font-mono font-semibold tracking-tight num ${colorCls}`}>
{value ?? '—'}
</div>
</div>
)
}
function LoadingPanel({ symbolsText }: { symbolsText: string }) {
return (
<div className="space-y-4">
<div className="rounded-card border border-accent/25 bg-accent/[0.04] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-1 text-xs text-muted">{symbolsText} · IC线</div>
</div>
<div className="h-8 w-8 rounded-full border-2 border-accent/25 border-t-accent animate-spin" />
</div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-base">
<div className="h-full w-1/2 rounded-full bg-accent/70 animate-pulse" />
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
<div key={item} className="rounded-btn border border-border bg-surface p-3">
<div className="h-2 w-10 rounded bg-accent/30 animate-pulse" />
<div className="mt-3 text-xs text-secondary">{item}</div>
</div>
))}
</div>
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between">
<div className="text-xs font-medium text-secondary"></div>
<div className="text-[11px] text-muted"></div>
</div>
<div className="mt-4 h-[260px] rounded-btn border border-border bg-base/60 p-4">
<div className="flex h-full items-end gap-2 opacity-70">
{[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
<div key={i} className="flex-1 rounded-t bg-accent/20 animate-pulse" style={{ height: `${h}%` }} />
))}
</div>
</div>
</div>
</div>
)
}
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<FactorBacktestResult | null>(null)
const columns = useQuery({
queryKey: ['backtest-factor-columns'],
queryFn: api.factorColumns,
})
// 按 group 分类的因子
const factorGroups = useMemo(() => {
const cols = columns.data?.columns ?? []
const groups: Record<string, FactorColumn[]> = {}
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 (
<div className="h-full min-h-0 overflow-hidden rounded-card border border-border bg-surface/80 grid grid-cols-1 xl:grid-cols-[18rem_minmax(0,1fr)]">
{/* 配置面板 */}
<section className="space-y-3 border-b xl:border-b-0 xl:border-r border-border bg-base/25 px-3 py-3 xl:overflow-y-auto">
<div className="border-b border-border/70 pb-2">
<div className="text-xs font-semibold text-foreground"></div>
<div className="mt-0.5 text-[10px] leading-4 text-muted"> 3 </div>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select
value={factorName}
onChange={e => setFactorName(e.target.value)}
className={INPUT_CLS}
>
{Object.entries(factorGroups).map(([group, cols]) => (
<optgroup key={group} label={group}>
{cols.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</optgroup>
))}
</select>
{factorDesc && (
<p className="mt-1 text-[11px] text-muted">{factorDesc}</p>
)}
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">
(=)
</label>
<input
type="text"
value={symbols}
onChange={e => 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`}
/>
</div>
<div className="rounded-btn border border-border bg-surface p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="text-xs font-medium text-foreground"></div>
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
{rangeTitle}
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-secondary block mb-1"></label>
<DatePicker
value={start}
onChange={setStart}
max={end || undefined}
placeholder="全部历史"
className="w-full"
buttonClassName="w-full justify-start"
align="left"
/>
</div>
<div>
<label className="text-[11px] text-secondary block mb-1"></label>
<DatePicker
value={end}
onChange={setEnd}
min={start || undefined}
className="w-full"
buttonClassName="w-full justify-start"
/>
</div>
</div>
<div className="mt-2 flex rounded-input bg-base/60 p-0.5">
<button type="button" onClick={() => applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3</button>
<button type="button" onClick={() => applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6</button>
<button type="button" onClick={() => applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1</button>
<button type="button" onClick={applyAllRange} className={`${rangeButtonCls('all')} flex-1`}></button>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={nGroups} onChange={e => setNGroups(Number(e.target.value))} className={INPUT_CLS}>
<option value={3}>3</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5"></label>
<select value={weight} onChange={e => setWeight(e.target.value as any)} className={INPUT_CLS}>
<option value="equal"></option>
<option value="factor_weight"></option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">()</label>
<input type="number" value={fees} onChange={e => setFees(e.target.value)}
className={INPUT_CLS} />
</div>
</div>
<button
onClick={() => run.mutate()}
disabled={run.isPending}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
bg-accent text-sm font-medium hover:bg-accent/90
transition-colors duration-150 ease-smooth disabled:opacity-50"
>
<Play className="h-3.5 w-3.5" />
{run.isPending ? '分析中…' : '开始因子分析'}
</button>
</section>
{/* 结果面板 */}
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
{result?.error && !result.ic_mean && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{result.error}
</div>
)}
{run.isError && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{String((run.error as any).message)}
</div>
)}
{!result && !run.isPending && (
<EmptyState
icon={BarChart3}
title="选择因子并开始分析"
hint="因子回测分析因子的预测能力 ( IC/IR ) 和分层收益差异。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
/>
)}
{run.isPending && result && (
<div className="rounded-card border border-accent/25 bg-accent/[0.04] px-4 py-3 text-xs text-secondary">
</div>
)}
{run.isPending && !result && (
<LoadingPanel symbolsText={symbols ? `${symbols.split(',').length} 只标的` : '全市场 · 当前区间'} />
)}
{result && result.ic_mean != null && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: [0.16, 1, 0.3, 1] }}
className="space-y-4"
>
{/* IC/IR 指标 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-foreground"></h3>
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted">
Rank IC ·
</span>
{result.elapsed_ms > 0 && (
<span className="flex items-center gap-1 text-[11px] text-muted">
<Clock className="h-3 w-3" />
<span className="num">{result.elapsed_ms.toFixed(0)} ms</span>
</span>
)}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
label="IC 均值"
value={result.ic_mean != null ? fmtPct(result.ic_mean) : null}
highlight={result.ic_mean != null
? result.ic_mean > 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
: undefined}
/>
<StatCard label="IC 标准差" value={result.ic_std != null ? fmtPct(result.ic_std) : null} />
<StatCard
label="ICIR"
value={result.ir != null ? result.ir.toFixed(2) : null}
highlight={result.ir != null
? Math.abs(result.ir) > 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
: undefined}
/>
<StatCard label="IC 胜率" value={result.ic_win_rate != null ? fmtPct(result.ic_win_rate) : null} />
</div>
</div>
{/* IC 时序图 */}
{result.ic_series.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<div className="bg-elevated px-4 py-2">
<span className="text-xs font-medium text-secondary">IC </span>
</div>
<div className="p-2">
<FactorICChart result={result} />
</div>
</div>
)}
{/* 分层净值 */}
{result.group_nav.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<div className="bg-elevated px-4 py-2">
<span className="text-xs font-medium text-secondary">线</span>
</div>
<div className="p-2">
<FactorGroupNavChart result={result} />
</div>
</div>
)}
{/* 分层统计表 */}
{result.group_stats.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-elevated">
<tr className="text-left text-secondary">
<th className="px-4 py-2.5 font-medium"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
<th className="px-4 py-2.5 font-medium text-right"></th>
</tr>
</thead>
<tbody>
{result.group_stats.map((g: GroupStat) => (
<tr key={g.group} className="border-t border-border hover:bg-elevated/50 transition-colors">
<td className="px-4 py-2 text-sm font-medium">{g.label}</td>
<td className={`px-4 py-2 text-right num ${priceColorClass(g.total_return)}`}>
{fmtPct(g.total_return)}
</td>
<td className={`px-4 py-2 text-right num ${priceColorClass(g.annual_return)}`}>
{fmtPct(g.annual_return)}
</td>
<td className="px-4 py-2 text-right num text-bear">{fmtPct(g.max_drawdown)}</td>
<td className="px-4 py-2 text-right num">{g.sharpe?.toFixed(2)}</td>
<td className="px-4 py-2 text-right num">{fmtPct(g.win_rate)}</td>
</tr>
))}
{/* 多空行 */}
{result.long_short_stats?.total_return != null && (
<tr className="border-t-2 border-accent/30 bg-accent/[0.03]">
<td className="px-4 py-2 text-sm font-medium text-accent">
({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
</td>
<td className={`px-4 py-2 text-right num font-medium ${priceColorClass(result.long_short_stats.total_return)}`}>
{fmtPct(result.long_short_stats.total_return as number)}
</td>
<td className="px-4 py-2 text-right num"></td>
<td className="px-4 py-2 text-right num text-bear">
{fmtPct(result.long_short_stats.max_drawdown as number)}
</td>
<td className="px-4 py-2 text-right num"></td>
<td className="px-4 py-2 text-right num"></td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* 数据概要 */}
<div className="flex items-center gap-4 text-[11px] text-muted">
<span>{result.n_symbols} </span>
<span>{result.n_dates} </span>
<span>run_id: {result.run_id}</span>
</div>
</motion.div>
)}
</section>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
const GROUP_COLORS = [
'#6366f1', // Q1 indigo
'#8b5cf6', // Q2 violet
'#f59e0b', // Q3 amber
'#f97316', // Q4 orange
'#ef4444', // Q5 red
'#ec4899', // Q6
'#14b8a6', // Q7
'#06b6d4', // Q8
'#84cc16', // Q9
'#a855f7', // Q10
]
interface Props {
result: FactorBacktestResult
}
export function FactorGroupNavChart({ result }: Props) {
const option = useMemo(() => {
if (!result.group_nav.length) return null
const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
// 多空净值
const lsNav = result.long_short_nav
const hasLS = lsNav && lsNav.length > 0
const series = groupCols.map((col, i) => ({
name: col,
type: 'line',
data: result.group_nav.map(r => r[col]),
symbol: 'none',
lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
}))
if (hasLS) {
series.push({
name: '多空',
type: 'line',
data: lsNav.map(r => r.value),
symbol: 'none',
lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
itemStyle: { color: '#fbbf24' },
})
}
return {
animation: false,
legend: {
show: false,
},
grid: { left: 56, right: 16, top: 12, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="display:flex;align-items:center;gap:4px">
<span style="width:8px;height:3px;border-radius:1px;background:${p.color};display:inline-block"></span>
${p.seriesName}
</span>
<span style="font-family:monospace">${(p.value as number).toFixed(4)}</span>
</div>`
}
return html
},
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
scale: true,
axisLabel: { color: '#64748b', fontSize: 10 },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
series,
} as any
}, [result.group_nav, result.long_short_nav, result.run_id])
const chartRef = useECharts(option, [result.run_id])
// 图例
const groupCols = result.group_nav.length > 0
? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
: []
return (
<div>
<div className="flex items-center gap-3 px-4 pb-2">
{groupCols.map((col, i) => (
<span key={col} className="flex items-center gap-1 text-[10px] text-secondary">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: GROUP_COLORS[i % GROUP_COLORS.length] }} />
{col}
</span>
))}
{result.long_short_nav?.length > 0 && (
<span className="flex items-center gap-1 text-[10px] text-secondary">
<span className="w-2 h-0.5 rounded bg-yellow-400" style={{ borderTop: '2px dashed #fbbf24' }} />
</span>
)}
</div>
<div ref={chartRef} className="h-[280px]" />
</div>
)
}
@@ -0,0 +1,88 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { FactorBacktestResult } from '@/lib/api'
interface Props {
result: FactorBacktestResult
}
export function FactorICChart({ result }: Props) {
const option = useMemo(() => {
if (!result.ic_series.length) return null
const dates = result.ic_series.map(r => r.date.slice(0, 10))
const values = result.ic_series.map(r => r.ic)
// 12期移动平均
const maWindow = 12
const ma: (number | null)[] = values.map((_, i) => {
if (i < maWindow - 1) return null
const slice = values.slice(i - maWindow + 1, i + 1)
return slice.reduce((a, b) => a + b, 0) / slice.length
})
return {
animation: false,
grid: { left: 50, right: 16, top: 16, bottom: 28 },
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="color:${p.color}">${p.seriesName}</span>
<span style="font-family:monospace">${(p.value * 100).toFixed(2)}%</span>
</div>`
}
return html
},
},
xAxis: {
type: 'category',
data: dates,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisLine: { lineStyle: { color: '#334155' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
series: [
{
name: 'IC',
type: 'bar',
data: values.map(v => ({
value: v,
itemStyle: {
color: v >= 0
? 'rgba(240,68,56,0.6)'
: 'rgba(18,183,106,0.6)',
},
})),
barMaxWidth: 6,
},
{
name: `MA${maWindow}`,
type: 'line',
data: ma,
smooth: true,
symbol: 'none',
lineStyle: { color: '#f59e0b', width: 1.5 },
z: 10,
},
],
} as any
}, [result.ic_series])
const chartRef = useECharts(option, [result.run_id])
return <div ref={chartRef} className="h-[200px]" />
}
@@ -0,0 +1,63 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { EChartsOption } from 'echarts'
interface DistBin {
range: string
count: number
ratio: number
}
/**
* 收益分布直方图 — 全量模拟专用的候选标的收益分布。
* 柱子颜色按收益正负区分(正红负绿),零轴居中。
*/
export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
const option = useMemo<EChartsOption>(() => {
const cats = distribution.map(d => d.range)
const vals = distribution.map(d => d.count)
// 判断每档是正还是负(按 range 字符串首字符 +/~)
const colors = distribution.map(d => {
const lo = parseFloat(d.range)
// 中心档(跨 0) 用中性色
if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
return lo >= 0 ? '#ef4444' : '#22c55e'
})
return {
grid: { left: 48, right: 16, top: 24, bottom: 56 },
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const p = Array.isArray(params) ? params[0] : params
const bin = distribution[p.dataIndex]
if (!bin) return ''
return `${bin.range}<br/>数量: ${bin.count}<br/>占比: ${(bin.ratio * 100).toFixed(1)}%`
},
},
xAxis: {
type: 'category',
data: cats,
axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
axisLine: { lineStyle: { color: '#3f3f46' } },
},
yAxis: {
type: 'value',
axisLabel: { color: '#a1a1aa', fontSize: 10 },
splitLine: { lineStyle: { color: '#27272a' } },
},
series: [
{
type: 'bar',
data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
barWidth: '90%',
},
],
}
}, [distribution])
const chartRef = useECharts(option, [distribution])
return <div ref={chartRef} className="h-48 w-full" />
}
@@ -0,0 +1,209 @@
import { useMemo } from 'react'
import { useECharts } from './useECharts'
import type { StrategyBacktestResult } from '@/lib/api'
interface Props {
result: StrategyBacktestResult
}
export function StrategyNavChart({ result }: Props) {
const option = useMemo(() => {
if (!result.equity_curve.length) return null
const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const axisMoneyFmt = (v: number) => {
if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}`
return moneyFmt.format(v)
}
const dates = result.equity_curve.map(r => r.date.slice(0, 10))
const navValues = result.equity_curve.map(r => r.value)
const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
const hasBenchmark = benchmarkValues.some(v => v != null)
const ddValues = result.drawdown_curve.map(r => r.value * 100)
return {
animation: false,
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: { backgroundColor: '#334155' },
},
grid: [
{ left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
{ left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
],
xAxis: [
{
type: 'category', data: dates, gridIndex: 0,
axisLabel: { show: false }, axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: '#334155' } },
},
{
type: 'category', data: dates, gridIndex: 1,
axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
axisTick: { show: false },
axisPointer: { show: true, type: 'line' },
axisLine: { lineStyle: { color: '#334155' } },
},
],
yAxis: [
{
type: 'value', gridIndex: 0,
scale: true,
name: hasBenchmark ? '上证点位' : '策略资金',
nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
fontSize: 10,
formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
},
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
{
type: 'value', gridIndex: 0,
position: 'right',
scale: true,
name: hasBenchmark ? '策略资金' : '',
nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
axisLabel: {
show: hasBenchmark,
color: '#64748b',
fontSize: 10,
formatter: axisMoneyFmt,
},
splitLine: { show: false },
axisLine: { show: false },
},
{
type: 'value', gridIndex: 1,
position: 'right',
max: 0,
axisLabel: {
color: '#64748b', fontSize: 10,
formatter: (v: number) => `${v.toFixed(1)}%`,
},
splitLine: { lineStyle: { color: '#1e293b' } },
axisLine: { show: false },
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1],
filterMode: 'filter',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
},
{
type: 'slider',
xAxisIndex: [0, 1],
filterMode: 'filter',
height: 16,
bottom: 10,
borderColor: 'rgba(148,163,184,0.18)',
backgroundColor: 'rgba(15,23,42,0.55)',
fillerColor: 'rgba(59,130,246,0.18)',
handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
textStyle: { color: '#64748b', fontSize: 10 },
brushSelect: false,
},
],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15,23,42,0.95)',
borderColor: 'rgba(148,163,184,0.2)',
textStyle: { color: '#e2e8f0', fontSize: 12 },
formatter: (params: any) => {
const date = params[0]?.axisValue ?? ''
let html = `<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${date}</div>`
for (const p of params) {
if (p.value == null) continue
const isDrawdown = p.seriesName === '回撤'
const isBenchmark = p.seriesName === '同期上证指数'
html += `<div style="display:flex;justify-content:space-between;gap:16px">
<span style="color:${p.color}">${p.seriesName}</span>
<span style="font-family:monospace">${
isDrawdown
? `${(p.value as number).toFixed(2)}%`
: isBenchmark
? `${valueFmt.format(p.value as number)}`
: moneyFmt.format(p.value as number)
}</span>
</div>`
}
return html
},
},
series: [
{
name: '净值',
type: 'line',
xAxisIndex: 0,
yAxisIndex: hasBenchmark ? 1 : 0,
data: navValues,
symbol: 'none',
lineStyle: { color: '#3b82f6', width: 2.2 },
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(59,130,246,0.15)' },
{ offset: 1, color: 'rgba(59,130,246,0.01)' },
],
} as any,
},
},
...(hasBenchmark ? [{
name: '同期上证指数',
type: 'line',
xAxisIndex: 0,
yAxisIndex: 0,
data: benchmarkValues,
symbol: 'none',
connectNulls: true,
lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
}] : []),
{
name: '回撤',
type: 'line',
xAxisIndex: 1,
yAxisIndex: 2,
data: ddValues,
symbol: 'none',
lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
areaStyle: { color: 'rgba(240,68,56,0.12)' },
},
],
} as any
}, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
const chartRef = useECharts(option, [result.run_id])
return (
<div>
<div className="flex flex-wrap items-center gap-4 px-4 pb-2">
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded bg-accent" />
</span>
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded bg-red-400/60" />
</span>
{(result.benchmark_curve?.length ?? 0) > 0 && (
<span className="flex items-center gap-1.5 text-[10px] text-secondary">
<span className="w-3 h-0.5 rounded border-t border-dashed border-slate-400/60" />
</span>
)}
<span className="ml-auto text-[10px] text-muted"> · </span>
</div>
<div ref={chartRef} className="h-[282px]" />
</div>
)
}
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
/**
* ECharts 实例管理 Hook — 自动初始化/resize/销毁。
* 返回 ref 绑定到容器 div,和 setOption 方法。
*/
export function useECharts(
option: EChartsOption | null,
deps: any[] = [],
) {
const chartRef = useRef<HTMLDivElement>(null)
const instanceRef = useRef<ECharts | null>(null)
// 初始化 / 销毁
useEffect(() => {
if (!chartRef.current) return
instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
const handleResize = () => instanceRef.current?.resize()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
instanceRef.current?.dispose()
instanceRef.current = null
}
}, [])
// 更新 option
useEffect(() => {
if (!instanceRef.current || !option) return
instanceRef.current.setOption(option, { notMerge: true })
}, [option, ...deps])
return chartRef
}
@@ -0,0 +1,170 @@
import { useEffect, useMemo, useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { Clock, X } from 'lucide-react'
import { StockPanel } from '@/components/StockPanel'
import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import type { StrategyBacktestTrade } from '@/lib/api'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
interface Props {
trade: StrategyBacktestTrade | null
onClose: () => void
}
function addDays(date: string, days: number): string {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d.toISOString().slice(0, 10)
}
function fmtMoney(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
const n = Number(v)
const abs = Math.abs(n)
if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}`
return n.toFixed(0)
}
function fmtSignedMoney(v: number | null | undefined): string {
if (v == null || Number.isNaN(Number(v))) return '—'
const prefix = Number(v) > 0 ? '+' : ''
return `${prefix}${fmtMoney(v)}`
}
export function TradeKlineModal({ trade, onClose }: Props) {
const [showIntraday, setShowIntraday] = useState(false)
useEffect(() => {
if (!trade) return
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [trade, onClose])
useEffect(() => {
if (trade) setShowIntraday(false)
}, [trade])
const dateRange = useMemo(() => {
if (!trade) return null
return {
start: addDays(String(trade.entry_date).slice(0, 10), -45),
end: addDays(String(trade.exit_date).slice(0, 10), 20),
}
}, [trade])
const ranges = useMemo<ChartRange[]>(() => {
if (!trade) return []
return [{
start: String(trade.entry_date).slice(0, 10),
end: String(trade.exit_date).slice(0, 10),
label: '持仓区间',
color: 'rgba(59,130,246,0.07)',
}]
}, [trade])
const priceLines = useMemo<ChartPriceLine[]>(() => {
if (!trade) return []
const start = String(trade.entry_date).slice(0, 10)
const end = String(trade.exit_date).slice(0, 10)
return [
{
value: Number(trade.entry_price),
label: `买入价 ${fmtPrice(trade.entry_price)}`,
color: '#C74040',
start,
end,
},
{
value: Number(trade.exit_price),
label: `卖出价 ${fmtPrice(trade.exit_price)}`,
color: '#2D9B65',
start,
end,
},
]
}, [trade])
return (
<AnimatePresence>
{trade && dateRange && (
<div className="fixed inset-0 z-[70] flex items-center justify-center">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="relative flex max-h-[94vh] w-[92vw] max-w-[1120px] flex-col overflow-hidden rounded-card border border-border bg-base shadow-2xl"
>
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-foreground">{trade.symbol}</span>
<span className="truncate text-sm text-foreground">{trade.name || '交易回放'}</span>
<span className="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-[10px] text-accent"></span>
</div>
<div className="mt-1 text-[11px] text-muted">
{String(trade.entry_date).slice(0, 10)} {String(trade.exit_date).slice(0, 10)} · {trade.duration ?? '—'}
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<div className="text-right">
<div className="text-muted"> / </div>
<div className="num text-foreground">{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}</div>
</div>
<div className="text-right">
<div className="text-muted"></div>
<div className={`num font-semibold ${priceColorClass(trade.pnl_amount ?? trade.pnl_pct)}`}>
{fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
</div>
</div>
<button
onClick={() => setShowIntraday((v) => !v)}
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
showIntraday
? 'border border-accent/30 bg-accent/15 text-accent'
: 'border border-border bg-elevated text-secondary hover:border-accent/30'
}`}
>
<Clock className="h-3 w-3" />
</button>
<button
onClick={onClose}
className="rounded-btn p-1 text-secondary transition-colors hover:bg-elevated hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto p-4">
<StockPanel
symbol={trade.symbol}
height={520}
dateRange={dateRange}
ranges={ranges}
priceLines={priceLines}
showLimitMarkers={false}
showMarkerToggle={false}
showIntraday={showIntraday}
onSelectDate={() => { if (!showIntraday) setShowIntraday(true) }}
/>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
)
}