import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react' import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react' import { api, type StrategyBacktestResult, type StrategyBacktestTrade, type StrategyDetail, type StrategyParamDef, } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { tierRank } from '@/lib/capability-labels' import { storage } from '@/lib/storage' import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format' import { boardTag } from '@/lib/board' import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns' import { SignalPicker } from '@/components/screener/SignalPicker' import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask' import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries' import { EmptyState } from '@/components/EmptyState' import { DatePicker } from '@/components/DatePicker' import { StrategyNavChart } from './charts/StrategyNavChart' import { ReturnDistributionChart } from './charts/ReturnDistributionChart' import { TradeKlineModal } from './components/TradeKlineModal' import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions' 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) type QuickRangeUnit = 'month' | 'year' | 'all' type QuickRangeConfig = { id: string; enabled: boolean; unit: QuickRangeUnit; value: number } const QUICK_RANGE_LIMITS = { month: { min: 1, max: 120 }, year: { min: 1, max: 10 }, } as const const DEFAULT_QUICK_RANGES: QuickRangeConfig[] = [ { id: 'range-1', enabled: true, unit: 'month', value: 3 }, { id: 'range-2', enabled: true, unit: 'month', value: 6 }, { id: 'range-3', enabled: true, unit: 'year', value: 1 }, { id: 'range-4', enabled: true, unit: 'all', value: 0 }, ] const quickRangeValue = (unit: QuickRangeUnit, value: unknown, fallback: number) => { if (unit === 'all') return 0 const limits = QUICK_RANGE_LIMITS[unit] const num = Number(value) const safe = Number.isFinite(num) ? Math.round(num) : fallback return clamp(safe, limits.min, limits.max) } const normalizeQuickRange = (raw: unknown, fallback: QuickRangeConfig): QuickRangeConfig => { const obj = raw && typeof raw === 'object' ? raw as Partial : {} const unit: QuickRangeUnit = obj.unit === 'month' || obj.unit === 'year' || obj.unit === 'all' ? obj.unit : fallback.unit const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : fallback.enabled return { id: fallback.id, enabled, unit, value: quickRangeValue(unit, obj.value, fallback.value) } } const normalizeQuickRanges = (raw: unknown) => { const items = Array.isArray(raw) ? raw : [] const ranges = DEFAULT_QUICK_RANGES.map((fallback, index) => { const byId = items.find(item => item && typeof item === 'object' && (item as { id?: unknown }).id === fallback.id) return normalizeQuickRange(byId ?? items[index], fallback) }) return ranges.some(range => range.enabled) ? ranges : ranges.map((range, index) => index === 0 ? { ...range, enabled: true } : range) } const loadQuickRanges = () => normalizeQuickRanges(storage.strategyBacktestQuickRanges.get(DEFAULT_QUICK_RANGES)) const quickRangeMonths = (range: QuickRangeConfig) => range.unit === 'year' ? range.value * 12 : range.value const quickRangeLabel = (range: QuickRangeConfig) => range.unit === 'all' ? '全部' : range.unit === 'year' ? `${range.value}年` : `${range.value}个月` const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all' ? '全部历史' : range.unit === 'year' ? `近 ${range.value} 年` : `近 ${range.value} 个月` 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` const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100] const BADGE_CLS_MAP: Record = { builtin: 'bg-secondary/10 text-muted border-border', ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30', custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30', } const FIELD_LABEL: Record = {} for (const c of BUILTIN_COLUMNS) { if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label } Object.assign(FIELD_LABEL, { change_pct: '涨跌幅', consecutive_limit_ups: '连板', momentum_60d: '60D动量', turnover_rate: '换手率', rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24', vol_ratio_5d: '量比', vol_ratio_20d: '20日量比', macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱', boll_upper: '布林上轨', boll_lower: '布林下轨', }) const BOARD_OPTIONS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] const BASIC_FILTER_FIELDS = [ { key: 'price_min', label: '最低价', unit: '元' }, { key: 'price_max', label: '最高价', unit: '元' }, { key: 'amount_min', label: '最低成交额', unit: '亿', scale: 1e8 }, { key: 'market_cap_min', label: '最低总市值', unit: '亿', scale: 1e8 }, { key: 'turnover_min', label: '最低换手率', unit: '%' }, { key: 'turnover_max', label: '最高换手率', unit: '%' }, ] type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range' type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin' const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [ { id: 'all', label: '全部' }, { id: 'custom', label: '自定义' }, { id: 'ai', label: 'AI' }, { id: 'builtin', label: '内置' }, ] const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [ { id: 'params', label: '策略参数' }, { id: 'filter', label: '基础过滤' }, { id: 'entry', label: '买入触发器' }, { id: 'exit', label: '卖出触发器' }, { id: 'scoring', label: '评分权重' }, { id: 'risk', label: '风控' }, { id: 'range', label: '回测范围' }, ] const toSignalId = (sig: string) => (sig.startsWith('signal_') || sig.startsWith('csg_')) ? sig : `signal_${sig}` const numOrNull = (v: string) => v === '' || Number.isNaN(Number(v)) ? null : Number(v) const clamp = (v: number, min?: number, max?: number) => { let next = v if (min != null) next = Math.max(next, min) if (max != null) next = Math.min(next, max) return next } const strategyDefaultParams = (detail: StrategyDetail) => { const values: Record = { ...detail.params_defaults } detail.params.forEach(p => { if (!(p.id in values)) values[p.id] = p.default }) return values } const buildDefaultOverrides = (detail: StrategyDetail) => ({ basic_filter: { ...detail.basic_filter }, entry_signals: detail.entry_signals.map(toSignalId), exit_signals: detail.exit_signals.map(toSignalId), scoring: { ...detail.scoring }, stop_loss: detail.stop_loss, trailing_stop: detail.trailing_stop, trailing_take_profit_activate: detail.trailing_take_profit_activate, trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown, score_min: null, score_max: null, max_hold_days: detail.max_hold_days, }) const fmtMoney = (v: number | null | undefined) => { if (v == null || Number.isNaN(v)) return '—' return v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } const fmtSignedMoney = (v: number | null | undefined) => { if (v == null || Number.isNaN(v)) return '—' const sign = v > 0 ? '+' : '' return `${sign}${fmtMoney(v)}` } const fmtShares = (v: number | null | undefined) => { if (v == null || Number.isNaN(v)) return '—' return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 }) } const fmtLots = (v: number | null | undefined) => { if (v == null || Number.isNaN(v)) return '—' return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) } const statValueColor = (v: number | null | undefined) => { if (v == null || Number.isNaN(v) || v === 0) return '#f8fafc' return v > 0 ? '#f87171' : '#34d399' } function ExitReasonBadge({ reason }: { reason: string }) { const config: Record = { signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' }, stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' }, trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' }, trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' }, max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' }, pending_exit: { label: '待卖', cls: 'bg-orange-400/10 text-orange-400 border-orange-400/30' }, end: { label: '期末', cls: 'bg-secondary/10 text-secondary border-border' }, } const c = config[reason] ?? { label: reason, cls: 'bg-elevated text-muted border-border' } return ( {c.label} ) } type DailyTradeRow = { date: string buys: StrategyBacktestTrade[] sells: StrategyBacktestTrade[] buyValue: number sellValue: number realizedPnl: number cumulativePnl: number } function fmtPositionPct(v: number | null | undefined, digits = 2): string { if (v == null || Number.isNaN(v)) return '—' return `${(Math.abs(v) * 100).toFixed(digits)}%` } function fmtScore(v: number | null | undefined): string { if (v == null || Number.isNaN(Number(v))) return '—' return Number(v).toFixed(1) } function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void }) { const isBuy = side === 'buy' const tag = boardTag(trade.symbol) const price = isBuy ? trade.entry_price : trade.exit_price const amount = isBuy ? trade.entry_value : trade.exit_value const pnlColor = priceColorClass(trade.pnl_amount ?? trade.pnl_pct) const footerColor = isBuy ? 'text-secondary' : pnlColor const footerText = `仓位 ${fmtPositionPct(trade.position_pct, 2)}` const scoreText = fmtScore(trade.entry_score) const buyStrategy = strategyName || '策略' return ( ) } function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell' }) { const isBuy = side === 'buy' const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10) const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10) const price = isBuy ? trade.entry_price : trade.exit_price const amount = isBuy ? trade.entry_value : trade.exit_value return (
{date} {isBuy ? '买' : '卖'}
{fmtPrice(price)} {fmtMoney(amount)}
{signalDate && signalDate !== date && (
信号 {signalDate}
)}
) } function fmtDuration(ms: number): string { const s = ms / 1000 if (s < 1) return `${ms.toFixed(0)}ms` if (s < 60) return `${s.toFixed(1)}秒` const m = Math.floor(s / 60) const rest = Math.round(s % 60) return `${m}分${rest}秒` } function SharpeLabel() { const [open, setOpen] = useState(false) const [alignRight, setAlignRight] = useState(false) const ref = useRef(null) useEffect(() => { if (!open) return const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', onClick) return () => document.removeEventListener('mousedown', onClick) }, [open]) const toggle = () => { if (!open && ref.current) { const rect = ref.current.getBoundingClientRect() setAlignRight(rect.left + 240 > window.innerWidth) } setOpen(o => !o) } return ( 夏普 {open && ( 夏普比率 (Sharpe Ratio) 衡量单位波动风险换来的超额收益。 数值越高,收益相对波动越优秀; 短周期或交易次数少时容易偏高,仅供参考。 )} ) } function Stat({ label, value, color }: { label: ReactNode; value: string; color?: string }) { return (
{label}
{value}
) } function ConfigSection({ title, hint, actions, children }: { title: string; hint?: ReactNode; actions?: ReactNode; children: ReactNode }) { return (
{title} {hint && {hint}}
{actions &&
{actions}
}
{children}
) } const scoringToPct = (values: Record) => { const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0) if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record } const normalizePctWeights = (values: Record) => { const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0) if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record } function ScoringWeightRow({ name, weight, pct, editing, onChange }: { name: string weight: number pct: number editing: boolean onChange: (value: number) => void }) { const label = FIELD_LABEL[name] ?? name return (
{label} {editing ? ( onChange(Number(e.target.value))} className="h-1 flex-1 cursor-pointer accent-amber-400" /> ) : (
)} {editing ? weight : `${pct}%`}
) } function StrategyParamInput({ param, value, onChange }: { param: StrategyParamDef value: any onChange: (value: any) => void }) { if (param.type === 'bool') { const checked = value === true || value === 'true' || value === 'True' || value === true return ( ) } if (param.type === 'select') { return ( ) } return ( ) } function StockPoolPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) { const symbols = useMemo(() => value.split(',').map(s => s.trim()).filter(Boolean), [value]) const [query, setQuery] = useState('') const [open, setOpen] = useState(false) const [symbolNames, setSymbolNames] = useState>({}) const ref = useRef(null) const search = useQuery({ queryKey: QK.instrumentSearch(query), queryFn: () => api.instrumentSearch(query), enabled: query.trim().length > 0, staleTime: 30_000, }) const results = search.data?.results ?? [] useEffect(() => { if (results.length === 0) return setSymbolNames(prev => { const next = { ...prev } results.forEach(r => { if (r.name) next[r.symbol] = r.name }) return next }) }, [results]) useEffect(() => { const onClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } document.addEventListener('mousedown', onClick) return () => document.removeEventListener('mousedown', onClick) }, []) const setSymbols = (next: string[]) => onChange(Array.from(new Set(next)).join(',')) const addSymbol = (symbol: string, name?: string | null) => { if (name) setSymbolNames(prev => ({ ...prev, [symbol]: name })) setSymbols([...symbols, symbol]) setQuery('') setOpen(false) } const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol)) return (
{ setQuery(e.target.value); setOpen(true) }} onFocus={() => { if (query.trim()) setOpen(true) }} placeholder="搜索股票名称/代码添加股票池" className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none" /> {open && results.length > 0 && (
{results.map(r => { const added = symbols.includes(r.symbol) return ( ) })}
)}
{symbols.length === 0 ? ( 留空 = 全市场,由基础过滤和策略条件筛选。 ) : symbols.map(symbol => { const name = symbolNames[symbol] return ( {symbol} {name && {name}} ) })}
) } export function StrategyBacktest() { const [saved] = useState(() => storage.strategyBacktestLast.get(null)) const [selectedStrategy, setSelectedStrategy] = useState(saved?.selectedStrategy ?? null) const [strategyGroup, setStrategyGroup] = useState('all') const [symbols, setSymbols] = useState(saved?.symbols ?? '') const [start, setStart] = useState(saved?.start ?? THREE_MONTHS_AGO) const [end, setEnd] = useState(saved?.end ?? TODAY) // 成交口径: 建仓/清仓可独立配置。向后兼容老 matching (派生为 entry=exit=matching)。 const [matching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1') const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1') const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t') const [fees, setFees] = useState(saved?.fees ?? '2') const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10') const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100') const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000') const [positionSizing, setPositionSizing] = useState<'equal' | 'score_weight'>(saved?.positionSizing ?? 'equal') const [simMode, setSimMode] = useState<'position' | 'full'>(saved?.mode ?? 'position') const [holdingDays, setHoldingDays] = useState(saved?.holdingDays ?? '5') const [settingsOpen, setSettingsOpen] = useState(false) // 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能 const [highGranularity, setHighGranularity] = useState(false) const { data: caps } = useCapabilities() const isFreeTier = tierRank(caps?.label ?? '') < 1 const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false) const [quickRanges, setQuickRanges] = useState(loadQuickRanges) const [settingsTab, setSettingsTab] = useState('params') const [editingScoring, setEditingScoring] = useState(false) const [scoringDraft, setScoringDraft] = useState>({}) const [strategyParams, setStrategyParams] = useState>(saved?.params ?? {}) const [overrides, setOverrides] = useState>(saved?.overrides ?? {}) // result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时, // 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑 // (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。 const [result, setResult] = useState(null) const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily') const [dailyPage, setDailyPage] = useState(0) const [tradePage, setTradePage] = useState(0) const [tradePageSize, setTradePageSize] = useState(10) const [selectedTrade, setSelectedTrade] = useState(null) const loadedStrategyRef = useRef(null) const strategies = useQuery({ queryKey: QK.screenerStrategies, queryFn: api.screenerStrategies, }) const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data]) const filteredStrategyList = useMemo(() => ( strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup) ), [strategyGroup, strategyList]) // 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略 // 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效, // 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物, // 策略失效后留着会造成"孤儿"状态:界面显示旧回测结果却无对应策略)。 useEffect(() => { if (strategies.isLoading || strategyList.length === 0) return if (selectedStrategy && !strategyList.some(st => st.id === selectedStrategy)) { setSelectedStrategy(null) setStrategyParams({}) setOverrides({}) setResult(null) } }, [strategies.isLoading, strategyList, selectedStrategy]) const strategyDetail = useQuery({ queryKey: ['strategy-detail', selectedStrategy], queryFn: () => api.strategyGet(selectedStrategy!), enabled: !!selectedStrategy, }) const backtestTask = useBacktestTask() const isPending = backtestTask?.isPending ?? false const dataStatus = useDataStatus() const earliestDate = dataStatus.data?.daily?.earliest_date ?? null const resetConfigFromDetail = (detail: StrategyDetail) => { setStrategyParams(strategyDefaultParams(detail)) setOverrides(buildDefaultOverrides(detail)) } // 刷新页面后: 从 localStorage 恢复未完成的回测任务 useEffect(() => { tryReconnect() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { const detail = strategyDetail.data if (!detail || loadedStrategyRef.current === detail.id) return loadedStrategyRef.current = detail.id if (saved?.selectedStrategy === detail.id && (saved.params || saved.overrides)) { setStrategyParams(saved.params ?? strategyDefaultParams(detail)) setOverrides(saved.overrides ?? buildDefaultOverrides(detail)) return } resetConfigFromDetail(detail) // eslint-disable-next-line react-hooks/exhaustive-deps }, [strategyDetail.data]) // 当全局回测任务完成时, 把结果写入组件 (切页回来也能恢复) useEffect(() => { if (backtestTask && !backtestTask.isPending && backtestTask.result) { setResult(backtestTask.result) setResultTab('daily') setDailyPage(0) setTradePage(0) storage.strategyBacktestLast.set({ selectedStrategy, symbols, start, end, matching, entryFill, exitFill, fees, maxPositions, maxExposure, initialCapital, positionSizing, mode: simMode, holdingDays, params: strategyParams, overrides, result: backtestTask.result, }) } }, [backtestTask]) const handleRun = () => { if (!selectedStrategy) return startBacktest({ strategy_id: selectedStrategy, symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null, start: start || null, end: end || undefined, matching, entry_fill: entryFill, exit_fill: exitFill, fees_pct: Number(fees) / 10000, max_positions: Number(maxPositions), max_exposure_pct: Number(maxExposure) / 100, initial_capital: Number(initialCapital), position_sizing: positionSizing, params: strategyParams, overrides, mode: simMode, holding_days: Number(holdingDays) || 5, }) } // 提取统计 const s = result?.stats const pick = (...keys: string[]) => { for (const k of keys) { if (s && k in s && s[k] != null) return s[k] } return null } const benchmarkReturn = useMemo(() => { const values = (result?.benchmark_curve ?? []) .map(r => Number(r.close ?? r.value)) .filter(v => Number.isFinite(v) && v > 0) if (values.length < 2) return null return values[values.length - 1] / values[0] - 1 }, [result?.benchmark_curve]) const strategyReturn = pick('total_return') as number | null const excessReturn = strategyReturn != null && benchmarkReturn != null ? strategyReturn - benchmarkReturn : null const applyRange = (months: number) => { setStart(monthsAgo(months)) setEnd(formatDate(new Date())) } const applyAllRange = () => { setStart(earliestDate ?? '') setEnd(formatDate(new Date())) } // 进入页面/还在加载时就点了"全部": earliestDate 就绪后回填, 让 DatePicker 显示真实起始日 useEffect(() => { if (earliestDate && start === '' && end === TODAY) { setStart(earliestDate) } }, [earliestDate, start, end]) const applyQuickRange = (range: QuickRangeConfig) => { if (range.unit === 'all') { applyAllRange() return } applyRange(quickRangeMonths(range)) } const saveQuickRanges = (next: QuickRangeConfig[]) => { const normalized = normalizeQuickRanges(next) storage.strategyBacktestQuickRanges.set(normalized) return normalized } const updateQuickRange = (id: string, patch: Partial>) => { setQuickRanges(prev => { const current = prev.find(range => range.id === id) if (patch.enabled === false && current?.enabled && prev.filter(range => range.enabled).length <= 1) return prev return saveQuickRanges(prev.map(range => range.id === id ? normalizeQuickRange({ ...range, ...patch }, range) : range )) }) } const visibleQuickRanges = quickRanges.filter(range => range.enabled) const matchedQuickRange = visibleQuickRanges.find(range => range.unit === 'all' ? end === TODAY && (start === earliestDate || start === '') : end === TODAY && start === monthsAgo(quickRangeMonths(range)) ) const rangeKey = matchedQuickRange?.id ?? 'custom' const rangeTitle = matchedQuickRange ? quickRangeTitle(matchedQuickRange) : '自定义区间' 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' }` const sortedTrades = useMemo(() => { return [...(result?.trades ?? [])].sort((a, b) => { const exitCmp = String(b.exit_date).localeCompare(String(a.exit_date)) if (exitCmp !== 0) return exitCmp return String(b.entry_date).localeCompare(String(a.entry_date)) }) }, [result?.trades]) const dailyTradeRows = useMemo(() => { const rows = new Map>() const ensure = (date: string) => { if (!rows.has(date)) { rows.set(date, { date, buys: [], sells: [], buyValue: 0, sellValue: 0, realizedPnl: 0 }) } return rows.get(date)! } for (const t of result?.trades ?? []) { const entryDate = String(t.entry_date).slice(0, 10) const exitDate = String(t.exit_date).slice(0, 10) const buyRow = ensure(entryDate) buyRow.buys.push(t) buyRow.buyValue += Number(t.entry_value ?? 0) const sellRow = ensure(exitDate) sellRow.sells.push(t) sellRow.sellValue += Number(t.exit_value ?? 0) sellRow.realizedPnl += Number(t.pnl_amount ?? 0) } let cumulativePnl = 0 return [...rows.values()] .sort((a, b) => a.date.localeCompare(b.date)) .map(row => { cumulativePnl += row.realizedPnl return { ...row, cumulativePnl } }) .reverse() }, [result?.trades]) const tradePageCount = sortedTrades.length ? Math.ceil(sortedTrades.length / tradePageSize) : 0 const dailyPageSize = 10 const dailyPageCount = dailyTradeRows.length ? Math.ceil(dailyTradeRows.length / dailyPageSize) : 0 const safeDailyPage = Math.min(dailyPage, Math.max(dailyPageCount - 1, 0)) const dailyStart = safeDailyPage * dailyPageSize const visibleDailyRows = dailyTradeRows.slice(dailyStart, dailyStart + dailyPageSize) const dailyEnd = Math.min(dailyStart + visibleDailyRows.length, dailyTradeRows.length) const safeTradePage = Math.min(tradePage, Math.max(tradePageCount - 1, 0)) const tradeStart = safeTradePage * tradePageSize const visibleTrades = sortedTrades.slice(tradeStart, tradeStart + tradePageSize) const tradeEnd = Math.min(tradeStart + visibleTrades.length, sortedTrades.length) const symbolNames = useMemo(() => { const names: Record = {} result?.trades.forEach(t => { if (t.name) names[t.symbol] = t.name }) return names }, [result?.trades]) const detail = strategyDetail.data const basicFilter = (overrides.basic_filter ?? {}) as Record const entrySignals = (overrides.entry_signals ?? []) as string[] const exitSignals = (overrides.exit_signals ?? []) as string[] const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring]) const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min) const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max) const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100) const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100) const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100) const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100) const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days) const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0 useEffect(() => { if (!editingScoring) setScoringDraft(scoringToPct(scoring)) }, [scoring, editingScoring]) const updateOverride = (key: string, value: any) => { setOverrides(prev => ({ ...prev, [key]: value })) } const updateBasicFilter = (key: string, value: any) => { updateOverride('basic_filter', { ...basicFilter, [key]: value }) } const startScoringEdit = () => { setScoringDraft(scoringToPct(scoring)) setEditingScoring(true) } const cancelScoringEdit = () => { setScoringDraft(scoringToPct(scoring)) setEditingScoring(false) } const saveScoringDraft = () => { updateOverride('scoring', normalizePctWeights(scoringDraft)) setEditingScoring(false) } const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== '' ? `评分 ${scoreMinValue}~${scoreMaxValue}` : scoreMinValue !== '' ? `评分 ≥${scoreMinValue}` : scoreMaxValue !== '' ? `评分 ≤${scoreMaxValue}` : '评分不过滤' const advancedSummary = detail ? [ detail.params.length > 0 ? `参数 ${detail.params.length}` : '无策略参数', basicFilter.enabled !== false ? '过滤开' : '过滤关', `买点 ${entrySignals.length}`, `卖点 ${exitSignals.length}`, scoreFilterSummary, stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设', trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设', trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设', maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓', ].join(' · ') : '选择策略后可调整参数 / 过滤 / 买卖触发器 / 评分 / 风控' const selectedStrategyName = detail?.name ?? strategyList.find(st => st.id === selectedStrategy)?.name ?? '未选择策略' const selectedStrategySource = detail?.source ?? strategyList.find(st => st.id === selectedStrategy)?.source const stockPoolCount = symbols.split(',').map(s => s.trim()).filter(Boolean).length const stockPoolSummary = stockPoolCount > 0 ? `股票池 已限定 ${stockPoolCount} 只` : '股票池 全市场' const resultStartDate = result?.config?.start ?? result?.equity_curve?.[0]?.date ?? start const resultEndDate = result?.config?.end ?? result?.equity_curve?.[result.equity_curve.length - 1]?.date ?? end const resultTradeDays = result?.equity_curve?.length ?? 0 const executionStats = (result?.stats?.execution ?? {}) as Record const executionSummary = [ ['buy_no_slot', '满仓未买'], ['buy_exposure', '仓位上限'], ['buy_score_filter', '评分过滤'], ['buy_limit_up', '涨停未买'], ['buy_suspended', '停牌未买'], ['sell_limit_down', '跌停阻塞'], ['sell_suspended', '停牌阻塞'], ['pending_exit', '待卖阻塞'], ] .map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) })) .filter(item => item.value > 0) return (
{/* 配置面板 */}
{/* 高颗粒回测(分钟K)— 开发中占位 */}
分钟K {isFreeTier && ( Starter+ )}
{/* 高颗粒开启时的警告条 */} {highGranularity && !isFreeTier && (
高颗粒回测(开发中) :将结合每日分钟K进行更精确的回测。 ⚠️ 此功能尚未完成,且开启后会显著拖慢回测速度、占用大量资源。
)}
{STRATEGY_GROUPS.map(group => ( ))}
{strategies.isLoading && ( 加载中… )} {!strategies.isLoading && filteredStrategyList.length === 0 && ( 当前分组暂无策略 )} {filteredStrategyList.map(st => ( ))}
{selectedStrategy && strategyDetail.isLoading && (
加载策略配置…
)}
回测区间
{rangeTitle}
{visibleQuickRanges.map(range => ( ))}
{rangeSettingsOpen && (
快捷区间 月 1-120 / 年 1-10
{quickRanges.map((range, index) => { const limits = range.unit === 'all' ? null : QUICK_RANGE_LIMITS[range.unit] return (
updateQuickRange(range.id, { value: Number(e.target.value) })} placeholder="—" className={`${INPUT_CLS} ${range.unit === 'all' ? 'opacity-50' : ''}`} />
) })}
)}
建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。
{simMode === 'position' && (
setInitialCapital(e.target.value)} className={INPUT_CLS} />
setMaxPositions(e.target.value)} className={INPUT_CLS} />
setMaxExposure(e.target.value)} className={INPUT_CLS} />
setFees(e.target.value)} className={INPUT_CLS} />
)} {simMode === 'position' && (
单票目标约 {Number.isFinite(targetPositionPct) ? targetPositionPct.toFixed(1) : '—'}%。最大总仓位控制资金投入;剩余现金不是新增持仓名额,只有实际卖出成功才释放持仓数。
)} {simMode === 'full' && (
全量模拟:每日将策略选出的全部候选独立买入,不受资金/最大持仓数限制;每一笔仍按策略卖点、止损、移动止盈/止损和最长持仓执行,用于评估策略本身的选股 + 交易规则质量。
)} {isPending ? ( ) : ( )}
{/* 结果面板 */}
{/* 模式切换: 仓位模拟 / 全量模拟 */}
{([['position', '仓位模拟'], ['full', '全量模拟']] as const).map(([val, label]) => ( ))}
{simMode === 'full' && ( maxHoldDaysValue !== '' ? (
策略最长 {maxHoldDaysValue}
) : (
兜底上限
{(['1', '5', '10', '20'] as const).map(d => ( ))}
) )}
{result?.error && (
{result.error}
)} {backtestTask?.error && (
{backtestTask.error}
)} {!result && !isPending && ( )} {isPending && (
{backtestTask?.progress ? `回测中 · 第 ${backtestTask.progress.day}/${backtestTask.progress.total} 天 (${backtestTask.progress.date})` : '正在重新计算回测…'}
{result ? '当前展示上次结果,完成后自动替换' : '正在加载回测数据…'}
{backtestTask?.progress && ( {((backtestTask.progress.day / backtestTask.progress.total) * 100).toFixed(0)}% )}
{backtestTask?.progress && (
)} )} {/* 旧全量模拟结果: 固定前瞻收益统计 (兼容历史缓存结果) */} {result && !result.error && result.stats && result.stats.mode === 'full' && result.stats.full_kind !== 'candidate_execution' && (
{result.strategy_info?.name ?? '策略'} 全量模拟 持有 {result.config?.holding_days ?? 5} 天 {String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)}
{/* 统计卡片 */}
候选样本 {result.stats.n_candidates ?? 0} (标的×信号日) 信号天数 {result.stats.n_days ?? 0} 日均候选 {result.stats.avg_daily_candidates ?? 0} 最佳 {fmtPct(result.stats.best)} 最差 {fmtPct(result.stats.worst)} 基准(上证) {fmtPct(result.stats.benchmark_return)}
{/* 累计超额曲线 (复用 StrategyNavChart) */} {result.equity_curve.length > 1 && (
累计收益曲线(日均复利)
)} {/* 收益分布直方图 */} {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
候选标的收益分布(持有 {result.config?.holding_days ?? 5} 天) 红=正收益 · 绿=负收益
)}
run_id: {result.run_id}
)} {result && !result.error && result.stats && !result.stats.error && (result.stats.mode !== 'full' || result.stats.full_kind === 'candidate_execution') && ( {/* 策略信息 */} {result.strategy_info && (
{result.strategy_info.name} {result.stats.full_kind === 'candidate_execution' && ( 全量独立执行 )} {result.strategy_info.source && ( {SRC_MAP[result.strategy_info.source] ?? ''} )}
{result.strategy_info.stop_loss != null && ( 止损 {fmtPct(result.strategy_info.stop_loss)} )} {result.strategy_info.trailing_stop != null && ( 移损 {fmtPct(result.strategy_info.trailing_stop)} )} {result.strategy_info.trailing_take_profit_activate != null && result.strategy_info.trailing_take_profit_drawdown != null && ( 回撤 {fmtPct(result.strategy_info.trailing_take_profit_activate)}-{fmtPct(result.strategy_info.trailing_take_profit_drawdown)} )} {result.strategy_info.max_hold_days != null && ( 最长 {result.strategy_info.max_hold_days} 天 )} {resultTradeDays > 0 && ( {String(resultStartDate).slice(0, 10)} ~ {String(resultEndDate).slice(0, 10)} {resultTradeDays} 天 )} {result.elapsed_ms > 0 && ( 0 ? '' : 'ml-auto'}`}> 总耗时 {fmtDuration(result.elapsed_ms)} )}
)} {/* 统计卡片 */}
} value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} /> {result.stats.full_kind === 'candidate_execution' ? ( ) : ( )}
{executionSummary.length > 0 && (
成交约束: {executionSummary.map((item, index) => ( {index > 0 ? '· ' : ''}{item.label} {item.value} ))}
)} {/* 净值曲线 */} {result.equity_curve.length > 0 && (
)} {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
独立候选交易收益分布 红=正收益 · 绿=负收益
)} {/* Tab: 按日期 / 交易明细 / 选股分析 */} {(result.trades.length > 0 || result.per_symbol_stats.length > 0) && (
{(['daily', 'trades', 'picks'] as const).map(t => ( ))}
{resultTab === 'daily' && (
{visibleDailyRows.map(row => ( ))}
日期 买入 卖出 当日收益 累计收益
{row.date}
买 {row.buys.length} / 卖 {row.sells.length}
{row.buys.length === 0 ? ( ) : (
{row.buys.map((t, i) => ( setSelectedTrade(t)} /> ))}
)}
{row.sells.length === 0 ? ( ) : (
{row.sells.map((t, i) => ( setSelectedTrade(t)} /> ))}
)}
{fmtSignedMoney(row.realizedPnl)} {fmtSignedMoney(row.cumulativePnl)}
{dailyTradeRows.length > 0 && (
显示 {dailyStart + 1}-{dailyEnd} 天 / 共 {dailyTradeRows.length} 天,每页 10 天
{safeDailyPage + 1} / {dailyPageCount}
)}
)} {resultTab === 'trades' && (
{visibleTrades.map((t: StrategyBacktestTrade, i: number) => ( ))}
标的 买入 卖出 仓位 / 手数 单票盈亏 持仓 原因
{t.name || t.symbol}
{t.symbol}
{fmtPct(t.position_pct, 2)}
{fmtLots(t.lots)}{fmtShares(t.shares)}
{fmtSignedMoney(t.pnl_amount)}
{fmtPct(t.pnl_pct)}
{t.duration} 天
{!!t.blocked_exit_days &&
阻塞 {t.blocked_exit_days} 天
}
{sortedTrades.length > 0 && (
显示 {tradeStart + 1}-{tradeEnd} 条 / 共 {sortedTrades.length} 条
{safeTradePage + 1} / {tradePageCount}
)}
)} {resultTab === 'picks' && ( {result.per_symbol_stats.map((r) => ( ))}
标的 选股次数 总收益 胜率 最佳 最差
{symbolNames[r.symbol] || r.symbol}
{r.symbol}
{r.n_trades} {fmtPct(r.total_return)} {fmtPct(r.win_rate)} {fmtPct(r.best)} {fmtPct(r.worst)}
)}
)}
run_id: {result.run_id}
)}
{settingsOpen && detail && ( <> setSettingsOpen(false)} className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[1px]" />
高级策略设置 {SRC_MAP[detail.source] ?? ''}
{detail.name}
{advancedSummary}
{ADVANCED_TABS.map(tab => ( ))}
触发 / 成交 / 仓位关系
触发器决定什么时候产生买卖信号;评分只在多个买点同时出现时排序。
成交口径可分别设置建仓/清仓:默认建仓次日开盘(避免未来函数)、清仓当日收盘(持仓中可盘中/收盘卖)。
退出优先级:止损/移动止损 > 卖点信号 > 到期平仓;到期只作兜底,不抢占卖点或风控。
最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。
{settingsTab === 'range' && ( 留空 = 全市场}>
默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。
)} {settingsTab === 'params' && ( {detail.params.length > 0 ? (
{detail.params.map(param => ( setStrategyParams(prev => ({ ...prev, [param.id]: value }))} /> ))}
) : (
当前策略没有可调参数。
)}
)} {settingsTab === 'filter' && (
{BASIC_FILTER_FIELDS.map(field => { const scale = field.scale ?? 1 const value = basicFilter[field.key] == null ? '' : Number(basicFilter[field.key]) / scale return ( ) })}
{BOARD_OPTIONS.map(board => { const boards = Array.isArray(basicFilter.boards) ? basicFilter.boards : [] const checked = boards.includes(board) return ( ) })}
)} {settingsTab === 'entry' && ( updateOverride('entry_signals', next)} />} > updateOverride('entry_signals', next)} kind="entry" /> )} {settingsTab === 'exit' && ( updateOverride('exit_signals', next)} />} > updateOverride('exit_signals', next)} kind="exit" /> )} {settingsTab === 'scoring' && ( {Object.entries(scoring).length > 0 ? (() => { const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring) const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0) return (
{Object.keys(scoring).map(key => ( setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))} /> ))}
总和 {editingScoring ? total : 100} 保存时自动归一化计算
{editingScoring && ( )}
) })() : (
当前策略没有评分权重。
)}
评分过滤 留空 = 不过滤;命中范围后按评分从高到低买入
例如最小值 71 表示只把评分 ≥ 71 的股票放入下一交易日买入预选池。
)} {settingsTab === 'risk' && (
)}
)} setSelectedTrade(null)} />
) }