Files
tick-stock-panel/frontend/src/pages/backtest/StrategyBacktest.tsx
T
shy3130 d9f7bc645d feat(backtest): 回测页支持分钟策略与分钟级成交展示
- 策略下拉改取 'all' 时段, 分钟策略入列表并加 sky 色「分钟」徽章
- 选中分钟策略条件化 UI: 隐藏分钟成交开关与 signal_next_minute,
  建仓口径固定显示「信号分钟收盘」, 展示本地分钟K覆盖提示与起始日越界警告
- 交易明细买入列渲染 HH:MM 分钟徽章 (entry_date 携带 YYYY-MM-DD HH:MM)
- SSE 连接前先 fetch 探测, HTTP 400 直接展示后端 detail,
  不再误入 EventSource 无限重连
2026-08-30 19:05:17 +08:00

2761 lines
138 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 { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap, ListPlus, HelpCircle, ChevronRight, AlertTriangle, Layers, BookmarkPlus } from 'lucide-react'
import {
api,
type StrategyBacktestResult,
type StrategyBacktestTrade,
type StrategyDetail,
type StrategyParamDef,
type ScoringDirection,
REGIME_STATE_LABELS,
REGIME_STATE_COLORS,
} from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
import { boardTag } from '@/lib/board'
import { boardTag as boardBadge } from '@/components/stock-table/primitives'
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
import { cnSignal } from '@/lib/signals'
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 { WarmupBadge } from '@/components/WarmupBadge'
import { DatePicker } from '@/components/DatePicker'
import { toast } from '@/components/Toast'
import { StrategyNavChart } from './charts/StrategyNavChart'
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
import { TradeKlineModal } from './components/TradeKlineModal'
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
import { WatchlistGroupMenu } from '@/components/WatchlistAddMenu'
import { ScoringEditor } from '@/components/ScoringEditor'
import { strategyResultCandidate } from './researchCandidates'
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<QuickRangeConfig> : {}
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`
/** 成交时序说明 — 黄色问号图标, 点击弹出气泡。
* 用 fixed 定位脱离父容器 overflow 裁剪(左侧表单是 overflow-y-auto, absolute 气泡会被裁)。 */
function FillRuleHint() {
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const iconRef = useRef<HTMLButtonElement>(null)
const handleOpen = () => {
if (!open && iconRef.current) {
const r = iconRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(v => !v)
}
// 气泡宽度 256px(w-64), 若右侧超出视口则向左对齐
const bubbleLeft = pos ? Math.min(pos.left, window.innerWidth - 256 - 8) : 0
return (
<div className="relative inline-flex items-center">
<button
ref={iconRef}
type="button"
onClick={handleOpen}
aria-label="查看成交时序说明"
aria-expanded={open}
title="查看成交时序说明"
className="inline-flex h-3.5 w-3.5 items-center justify-center text-yellow-500/80 transition-colors hover:text-yellow-500"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
<AnimatePresence>
{open && pos && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{ top: pos.top, left: bubbleLeft }}
className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
<div className="font-medium text-foreground mb-1.5">成交时序说明</div>
<div className="space-y-1">
<div><b className="text-foreground">建仓口径</b><b className="text-foreground">清仓口径</b>分别控制买卖信号出现后的成交时点。</div>
<div><b className="text-foreground">信号日收盘</b>仅适用于收盘前可确认的信号;收盘后确认的信号应选择<b className="text-foreground">次日开盘</b></div>
<div><b className="text-foreground">信号触发卖出</b>仅在分钟成交开启且卖出信号支持分钟回放时可用;分钟收盘确认后按下一分钟开盘成交。</div>
<div>买卖信号由<b className="text-foreground">策略触发器</b>决定,这里只控制信号出现后的成交时点。</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
}
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }
const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
const BADGE_CLS_MAP: Record<string, string> = {
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',
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
}
const FIELD_LABEL: Record<string, string> = {}
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: '布林下轨',
ma20_bias: 'MA20乖离率',
})
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' | 'composite'
const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [
{ id: 'all', label: '全部' },
{ id: 'custom', label: '自定义' },
{ id: 'ai', label: 'AI' },
{ id: 'composite', label: '叠加' },
{ 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
}
// 截断浮点长尾(如 0.07*100=7.000000000000001 → 7)。用于百分比派生显示。
const round4 = (v: number) => Math.round(v * 10000) / 10000
/**
* 数字输入框 — 解决"删除即跳最小值"问题。
* 受控 input 的 onChange 立即 clamp 会让用户键入低于 min 的中间值时被钳到 min,
* 无法平滑输入/删除重输。本组件: 输入时只更新文本草稿(不钳制), 失焦时才校正到 [min,max]。
*/
function NumberField({ value, onChange, min, max, step, className, placeholder }:
{
value: number | null
onChange: (v: number | null) => void
min?: number
max?: number
step?: number
className?: string
placeholder?: string
}) {
// 文本草稿: null 表示与外部 value 同步(无未提交编辑); 非 null 表示用户正在输入
const [draft, setDraft] = useState<string | null>(null)
// 显示值: 有草稿用草稿, 否则用外部 value (null 显示空)
const display = draft !== null ? draft : (value == null ? '' : String(value))
return (
<input
type="number"
value={display}
min={min}
max={max}
step={step}
placeholder={placeholder}
onChange={e => {
// 输入时只更新草稿 + 把原始数字推给父级(不钳制), 让用户自由编辑
setDraft(e.target.value)
onChange(numOrNull(e.target.value))
}}
onBlur={() => {
// 失焦时校正: 空值保持 null; 否则钳制到 [min,max]
const n = numOrNull(draft ?? '')
if (n != null && (min != null || max != null)) {
const clamped = clamp(n, min, max)
if (clamped !== n) onChange(clamped)
}
setDraft(null) // 清除草稿, 回到外部 value 同步
}}
className={className}
/>
)
}
const strategyDefaultParams = (detail: StrategyDetail) => {
const values: Record<string, any> = { ...detail.params_defaults }
detail.params.forEach(p => {
if (!(p.id in values)) values[p.id] = p.default
})
return values
}
const mergeStrategyParams = (detail: StrategyDetail, values?: Record<string, any> | null) => ({
...strategyDefaultParams(detail),
...(values ?? {}),
})
const normalizeStrategyOverrides = (detail: StrategyDetail, values?: Record<string, any> | null) => {
const next = { ...(values ?? {}) }
const savedScoring = next.scoring && typeof next.scoring === 'object' ? next.scoring : {}
next.scoring = next.scoring_replace === true
? { ...savedScoring }
: { ...detail.scoring, ...savedScoring }
next.scoring_directions = {
...(detail.scoring_directions ?? {}),
...(next.scoring_directions ?? {}),
}
next.scoring_replace = true
if (detail.execution_backend === 'matrix_native') {
// MatrixStrategy.compute_signals() owns entry/exit formulas. Remove both
// current and legacy persisted column overrides before any request.
delete next.entry_signals
delete next.exit_signals
}
return next
}
const buildDefaultOverrides = (detail: StrategyDetail) => normalizeStrategyOverrides(detail, {
basic_filter: { ...detail.basic_filter },
entry_signals: detail.entry_signals.map(toSignalId),
exit_signals: detail.exit_signals.map(toSignalId),
scoring: { ...detail.scoring },
scoring_directions: { ...(detail.scoring_directions ?? {}) },
scoring_replace: true,
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
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 strategyBacktestConfigSignature = (detail: StrategyDetail) => JSON.stringify({
execution_backend: detail.execution_backend,
basic_filter: detail.basic_filter,
params: detail.params,
params_defaults: detail.params_defaults,
scoring: detail.scoring,
scoring_directions: detail.scoring_directions,
entry_signals: detail.entry_signals,
exit_signals: detail.exit_signals,
stop_loss: detail.stop_loss,
take_profit: detail.take_profit,
trailing_stop: detail.trailing_stop,
trailing_take_profit_activate: detail.trailing_take_profit_activate,
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
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 'inherit'
return v > 0 ? '#f87171' : '#34d399'
}
/** 信号 ID → 可读名称映射 (内置 + 自定义), 供交易记录显示具体触发信号。 */
function useSignalNames(): Record<string, string> {
const customQ = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
return useMemo(() => {
const names: Record<string, string> = {}
for (const cs of customQ.data?.signals ?? []) names[`csg_${cs.id}`] = cs.name
return names
}, [customQ.data])
}
function ExitReasonBadge({ reason, signalId, signalNames }: { reason: string; signalId?: string | null; signalNames?: Record<string, string> }) {
const config: Record<string, { label: string; cls: string }> = {
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' },
take_profit: { label: '止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-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' }
// 信号类退出且能解析出具体信号名时, 显示具体信号而非笼统的"信号"
const specific = reason === 'signal' && signalId ? cnSignal(signalId, signalNames) : null
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${c.cls} ${specific ? 'max-w-[7rem] truncate' : ''}`} title={specific ?? c.label}>
{specific ?? c.label}
</span>
)
}
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, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void; signalNames?: Record<string, string> }) {
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 (
<button type="button" onClick={onClick} className={`inline-flex ${isBuy ? 'w-[14.5rem]' : 'w-[14.5rem]'} flex-col gap-0.5 rounded-btn border px-1.5 py-1 text-left text-[11px] leading-4 transition-colors hover:border-accent/45 hover:bg-elevated/60 focus:outline-none focus:ring-1 focus:ring-accent/40 ${
isBuy ? 'border-accent/25 bg-accent/5' : 'border-border/70 bg-base/45'
}`}>
<span className="flex items-center gap-1">
<span className={`shrink-0 rounded px-1 py-px text-[9px] font-medium ${
isBuy ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'
}`}>
{isBuy ? '买' : '卖'}
</span>
<span className="min-w-0 flex-1 truncate text-foreground">{trade.name || trade.symbol}</span>
{tag && <span className={`shrink-0 rounded px-1 text-[9px] font-medium ${isBuy ? 'bg-accent/20 text-accent' : 'bg-elevated text-secondary'}`}>{tag}</span>}
</span>
<span className="flex items-center justify-between gap-2 text-muted">
<span className="min-w-0 truncate">
<span className="font-mono">{trade.symbol}</span>
<span className="mx-1">·</span>
<span className="num">{fmtLots(trade.lots)}</span>
</span>
{isBuy ? (
<span className="num shrink-0 text-secondary">{fmtPrice(price)}</span>
) : (
<span className="flex shrink-0 items-center gap-1.5">
<span className="num text-secondary">{fmtPrice(price)}</span>
<ExitReasonBadge reason={trade.exit_reason} signalId={trade.exit_signal_id} signalNames={signalNames} />
</span>
)}
</span>
{isBuy ? (
<>
<span className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate text-muted" title={buyStrategy}>策略 {buyStrategy}</span>
<span className="shrink-0 rounded border border-accent/25 bg-accent/10 px-1.5 py-px font-mono text-[10px] text-accent">
评分 {scoreText}
</span>
</span>
<span className="flex items-center justify-between gap-2">
<span className="num font-medium text-foreground">{fmtMoney(amount)}</span>
<span className={`min-w-0 truncate text-right num ${footerColor}`}>{footerText}</span>
</span>
</>
) : (
<>
<span className="flex items-center justify-between gap-2">
<span className="text-muted">卖出</span>
<span className="num font-medium text-foreground">{fmtMoney(amount)}</span>
</span>
<span className="flex items-center justify-between gap-2">
<span className="text-muted">盈亏</span>
<span className={`flex shrink-0 items-center gap-1.5 text-right num font-medium ${pnlColor}`}>
<span>{fmtSignedMoney(trade.pnl_amount)}</span>
<span className="text-muted/40">/</span>
<span>{fmtPct(trade.pnl_pct)}</span>
</span>
</span>
</>
)}
</button>
)
}
function TradeLegCell({ trade, side, signalNames }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; signalNames?: Record<string, string> }) {
const isBuy = side === 'buy'
// 分钟策略入场携带 "YYYY-MM-DD HH:MM" (盘中触发分钟); 日线口径为纯日期
const raw = String(isBuy ? trade.entry_date : trade.exit_date)
const date = raw.slice(0, 10)
const minuteTime = raw.length > 10 ? raw.slice(11, 16) : ''
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
const signalId = isBuy ? trade.entry_signal_id : trade.exit_signal_id
const signalLabel = signalId ? cnSignal(signalId, signalNames) : null
const signalDateLabel = isBuy || trade.exit_reason === 'signal' ? '信号' : '触发'
return (
<div className="min-w-[8.25rem] rounded-btn border border-border/60 bg-base/35 px-2 py-1 text-xs leading-4">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-secondary">
成交 {date}
{minuteTime && (
<span className="ml-1 rounded border border-sky-500/30 bg-sky-500/10 px-1 py-px text-[9px] font-medium text-sky-400">{minuteTime}</span>
)}
</span>
<span className={`rounded px-1.5 py-px text-[10px] font-medium ${
isBuy ? 'bg-accent/15 text-accent' : 'bg-elevated text-secondary'
}`}>
{isBuy ? '买' : '卖'}
</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<span className="num text-foreground">{fmtPrice(price)}</span>
<span className="num font-medium text-foreground">{fmtMoney(amount)}</span>
</div>
{signalLabel && (
<div className="mt-0.5 text-[10px] text-accent/80 truncate" title={signalLabel}>{signalLabel}</div>
)}
{signalDate && (
<div className="mt-0.5 text-[10px] text-muted">{signalDateLabel} {signalDate}</div>
)}
</div>
)
}
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}秒`
}
const METRIC_HELP = {
avgReturn: {
title: '平均收益',
description: '所有已执行候选交易收益率的算术平均值。',
note: '容易受极端盈亏影响,建议与中位数一起看。',
},
medianReturn: {
title: '中位数收益',
description: '将每笔收益排序后位于中间的值。',
note: '比平均收益更不容易被少数极端样本扭曲。',
},
winRate: {
title: '胜率',
description: '盈利交易数占已完成交易数的比例。',
note: '胜率高不代表总收益一定高,还需结合盈亏比。',
},
profitFactor: {
title: '盈亏比',
description: '平均盈利幅度 ÷ 平均亏损幅度的绝对值。',
note: '大于 1 表示平均单笔盈利大于平均单笔亏损。',
},
totalReturn: {
title: '总收益',
description: '回测期末权益相对初始资金的累计收益率。',
note: '已反映回测中的仓位、费用、滑点和成交约束。',
},
annualReturn: {
title: '年化收益',
description: '将回测期总收益按复利折算为一年的收益率。',
note: '短周期回测的年化结果可能被明显放大。',
},
benchmarkReturn: {
title: '同期上证',
description: '同一回测区间内上证指数的累计收益率。',
note: '用于判断策略表现是否主要来自市场整体涨跌。',
},
excessReturn: {
title: '超额收益',
description: '策略总收益率减去同期上证指数收益率。',
note: '正值表示跑赢基准,负值表示跑输基准。',
},
sharpe: {
title: '夏普比率 (Sharpe Ratio)',
description: '收益序列的平均收益 ÷ 总波动,并按 252 期年化。',
note: '数值越高,单位波动获得的收益越多;小样本时仅供参考。',
},
sortino: {
title: '索提诺比率 (Sortino Ratio)',
description: '收益序列的平均收益 ÷ 下行偏差,并按 252 期年化。',
note: '只惩罚负收益波动,不将向上波动视为风险。',
},
maxDrawdown: {
title: '最大回撤',
description: '回测权益从历史高点到随后最低点的最大跌幅。',
note: '越接近 0 通常代表历史资金回撤越小。',
},
mcDrawdownMedian: {
title: '蒙卡回撤中位数',
description: '对交易收益有放回重抽样,各自计算最大回撤后取中位数。',
note: '表示交易顺序变化时较典型的最大回撤场景。',
},
mcDrawdown95: {
title: '蒙卡回撤 95% 边界',
description: '交易收益重抽样结果中偏悲观的最大回撤边界。',
note: '约有 95% 的模拟顺序回撤不劣于此值,但不是未来承诺。',
},
tradeCount: {
title: '交易数',
description: '回测期内已完成建仓和清仓的交易笔数。',
note: '样本越少,胜率和风险指标的稳定性越低。',
},
avgDuration: {
title: '平均持仓',
description: '所有已完成交易的平均持仓天数。',
note: '全量模式下每个候选独立执行后再汇总。',
},
finalEquity: {
title: '最终权益',
description: '回测结束时账户现金与持仓市值的合计。',
note: '已反映成交费用、滑点和仓位约束。',
},
} as const
type MetricHelpKey = keyof typeof METRIC_HELP
function MetricLabel({ label, metric }: { label: string; metric: MetricHelpKey }) {
const [open, setOpen] = useState(false)
const [alignRight, setAlignRight] = useState(false)
const ref = useRef<HTMLSpanElement>(null)
const help = METRIC_HELP[metric]
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 (
<span className="relative inline-flex items-center gap-1" ref={ref}>
{label}
<button
type="button"
onClick={toggle}
aria-label={`查看${label}说明`}
aria-expanded={open}
title={`查看${label}说明`}
className="inline-flex h-3.5 w-3.5 items-center justify-center text-muted transition-colors hover:text-accent"
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
{open && (
<span className={`absolute top-full z-50 mt-1.5 w-60 max-w-[calc(100vw-1.5rem)] rounded-lg border border-border bg-elevated px-3 py-2.5 text-[11px] leading-relaxed text-secondary shadow-xl ${alignRight ? 'right-0' : 'left-0'}`}>
<span className="block font-medium text-foreground">{help.title}</span>
<span className="mt-1 block">{help.description}</span>
<span className="mt-0.5 block text-warning">{help.note}</span>
</span>
)}
</span>
)
}
function Stat({ label, value, color }: { label: ReactNode; value: string; color?: string }) {
return (
<div className="min-w-0 rounded-btn border border-border/70 bg-elevated/70 px-3 py-2">
<div className="text-[11px] text-secondary">{label}</div>
<div
className="mt-1 break-words text-sm font-mono font-semibold leading-tight tracking-tight num xl:text-base"
style={{ color: color ?? 'inherit' }}
title={value}
>
{value}
</div>
</div>
)
}
function ConfigSection({ title, hint, actions, children }: { title: string; hint?: ReactNode; actions?: ReactNode; children: ReactNode }) {
return (
<div className="rounded-btn border border-border bg-surface/70 p-3">
<div className="flex items-start justify-between gap-3">
<div className="text-xs font-medium text-foreground">
{title}
{hint && <span className="ml-1 text-[10px] font-normal text-muted">{hint}</span>}
</div>
{actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
</div>
<div className="mt-3 space-y-2">{children}</div>
</div>
)
}
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 (
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">{param.label}</span>
<button
type="button"
onClick={() => onChange(!checked)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 cursor-pointer ${
checked ? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]' : 'bg-elevated'
}`}
aria-pressed={checked}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ${
checked ? 'translate-x-[18px]' : 'translate-x-0.5'
}`} />
</button>
</label>
)
}
if (param.type === 'select') {
return (
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">{param.label}</span>
<select value={value ?? param.default} onChange={e => onChange(e.target.value)} className={INPUT_CLS}>
{(param.options ?? []).map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</label>
)
}
return (
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">{param.label}</span>
<NumberField
value={value == null || value === '' ? null : Number(value)}
min={param.min}
max={param.max}
step={param.step ?? (param.type === 'int' ? 1 : 0.01)}
onChange={n => onChange(n == null ? '' : (param.type === 'int' ? Math.round(n) : n))}
className={INPUT_CLS}
/>
</label>
)
}
function StockPoolPicker({ value, onChange, assetType = 'stock' }: { value: string; onChange: (value: string) => void; assetType?: 'stock' | 'etf' }) {
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<Record<string, string>>({})
const ref = useRef<HTMLDivElement>(null)
const searchAssetTypes = assetType === 'etf' ? 'stock,etf' : 'stock'
const search = useQuery({
queryKey: QK.instrumentSearch(query, searchAssetTypes),
queryFn: () => api.instrumentSearch(query, 20, searchAssetTypes),
enabled: query.trim().length > 0,
staleTime: 30_000,
})
const results = search.data?.results ?? []
// 自选列表 — 供「从自选导入」一键填入回测范围
const watchlist = useQuery({
queryKey: QK.watchlist,
queryFn: () => api.watchlistList(),
staleTime: 30_000,
})
const watchlistEntries = watchlist.data?.symbols ?? []
const watchlistCounts = useMemo(() => {
// 多组并存: 一股计入每个所属分组
const counts: Record<string, number> = { ungrouped: 0 }
for (const entry of watchlistEntries) {
const gids = entry.group_ids ?? []
if (gids.length === 0) counts.ungrouped += 1
else for (const gid of gids) counts[gid] = (counts[gid] ?? 0) + 1
}
return counts
}, [watchlistEntries])
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))
// 按分组导入自选: 合并去重, 顺带回填股票名 ('all'=全部, null=未分组)
const importFromWatchlist = (groupId: string | null) => {
const entries = groupId === 'all'
? watchlistEntries
: groupId == null
? watchlistEntries.filter(entry => !(entry.group_ids?.length))
: watchlistEntries.filter(entry => !!entry.group_ids?.includes(groupId))
if (entries.length === 0) return
setSymbolNames(prev => {
const next = { ...prev }
entries.forEach(e => { if (e.name) next[e.symbol] = e.name })
return next
})
setSymbols([...symbols, ...entries.map(e => e.symbol)])
}
const watchlistCount = watchlistEntries.length
return (
<div className="space-y-2" ref={ref}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
type="text"
value={query}
onChange={e => { 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 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-card border border-border bg-base shadow-xl">
{results.map(r => {
const added = symbols.includes(r.symbol)
return (
<button
key={r.symbol}
type="button"
disabled={added}
onClick={() => addSymbol(r.symbol, r.name)}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
>
<span className="w-[78px] shrink-0 font-mono">{r.symbol}</span>
<span className="min-w-0 flex-1 truncate text-secondary">{r.name}</span>
{(() => {
const b = boardBadge(r.symbol)
return b && (
<span className={`shrink-0 px-1 py-0.5 rounded text-[10px] leading-none border ${b.color}`}>{b.label}</span>
)
})()}
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
</button>
)
})}
</div>
)}
</div>
{/* 操作按钮 — 紧贴输入框右侧 */}
<div className="flex shrink-0 items-center gap-1.5">
{/* 当前范围 — 有范围显示个数, 无范围显示全市场 */}
<span className={`whitespace-nowrap text-[11px] font-medium ${symbols.length === 0 ? 'text-amber-400' : 'text-accent'}`}>
{symbols.length === 0 ? '全市场' : `共 ${symbols.length} 只`}
</span>
<WatchlistGroupMenu
onSelect={importFromWatchlist}
disabled={watchlist.isLoading || watchlistCount === 0}
includeAll
counts={watchlistCounts}
total={watchlistCount}
disableEmpty
menuLabel="导入自选分组"
align="right"
triggerClassName="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
title="选择自选分组并加入回测范围"
ariaLabel="从自选分组导入回测范围"
>
<ListPlus className="h-3 w-3" />
{watchlist.isLoading ? '加载…' : watchlistCount === 0 ? '自选空' : `导入自选(${watchlistCount})`}
</WatchlistGroupMenu>
<button
type="button"
onClick={() => setSymbols([])}
disabled={symbols.length === 0}
className="inline-flex items-center gap-1 whitespace-nowrap rounded-input border border-border bg-surface px-2 py-1.5 text-[11px] text-secondary transition-colors hover:border-danger/50 hover:text-danger disabled:cursor-not-allowed disabled:opacity-50"
title="清空回测范围"
>
<X className="h-3 w-3" />
清空
</button>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{symbols.length === 0 ? (
<span className="text-[11px] text-muted">默认全市场回测,由基础过滤和策略条件筛选。</span>
) : symbols.map(symbol => {
const name = symbolNames[symbol]
return (
<span key={symbol} className="inline-flex items-center gap-1 rounded-btn border border-accent/30 bg-accent/10 px-2 py-1 text-[10px] text-accent">
<span className="font-mono">{symbol}</span>
{name && <span className="max-w-[7rem] truncate text-accent/80">{name}</span>}
<button type="button" onClick={() => removeSymbol(symbol)} className="text-accent/70 hover:text-accent">
<X className="h-3 w-3" />
</button>
</span>
)
})}
</div>
</div>
)
}
export function StrategyBacktest() {
const queryClient = useQueryClient()
const signalNames = useSignalNames()
const [saved] = useState(() => storage.strategyBacktestLast.get(null))
const [selectedStrategy, setSelectedStrategy] = useState<string | null>(saved?.selectedStrategy ?? null)
const [strategyGroup, setStrategyGroup] = useState<StrategyGroup>('all')
const [symbols, setSymbols] = useState(saved?.symbols ?? '')
const [assetType, setAssetType] = useState<'stock' | 'etf'>(saved?.assetType ?? 'stock')
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' | 'signal_next_minute'>(
saved ? (saved.exitFill ?? saved.matching ?? 'close_t') : 'open_t+1',
)
const [fees, setFees] = useState(saved?.fees ?? '2')
const [stampTax, setStampTax] = useState(saved?.stampTax ?? '1')
const [slippage, setSlippage] = useState(saved?.slippage ?? '5')
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 [highGranularity, setHighGranularity] = useState(saved?.minuteFill ?? false)
// 市场环境过滤(空=不过滤)
const [regimeStates, setRegimeStates] = useState<string[]>(saved?.regimeStates ?? [])
const [regimeMinScore, setRegimeMinScore] = useState<number | ''>(saved?.regimeMinScore ?? '')
const [settingsOpen, setSettingsOpen] = useState(false)
// 分钟K成交价细化: 不改变信号日或成交日, 依赖分钟K批量数据
const { data: caps } = useCapabilities()
const hasMinuteBatch = !!caps?.capabilities?.['kline.minute.batch']
const toggleMinuteFill = () => {
if (!hasMinuteBatch) return
if (highGranularity) {
if (exitFill === 'signal_next_minute') setExitFill('close_t')
}
setHighGranularity(value => !value)
}
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
const [settingsTab, setSettingsTab] = useState<AdvancedSettingsTab>('params')
const [strategyParams, setStrategyParams] = useState<Record<string, any>>(saved?.params ?? {})
const [overrides, setOverrides] = useState<Record<string, any>>(saved?.overrides ?? {})
// result 不从 localStorage 恢复:它是运行产物(净值/交易),大且易过时,
// 跨会话/拉新代码后自动渲染一个可能对应已失效策略的旧结果会造成困惑
// (切页不卸载组件,内存中的 result 仍保留,无需靠 localStorage 恢复)。
const [result, setResult] = useState<StrategyBacktestResult | null>(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<StrategyBacktestTrade | null>(null)
const loadedStrategyRef = useRef<string | null>(null)
const strategies = useQuery({
queryKey: QK.screenerStrategies(assetType, 'all'),
queryFn: () => api.screenerStrategies(assetType, 'all'),
})
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: QK.strategyDetail(selectedStrategy ?? ''),
queryFn: () => api.strategyGet(selectedStrategy!),
enabled: !!selectedStrategy,
})
const backtestTask = useBacktestTask()
const isPending = backtestTask?.isPending ?? false
const saveCandidate = useMutation({
mutationFn: () => {
if (!result) throw new Error('暂无策略结果')
return api.researchCandidateCreate(strategyResultCandidate(result))
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QK.researchCandidates })
toast('已保存到候选方案', 'success')
},
onError: error => toast(`保存失败 · ${String((error as Error).message || error)}`, 'error'),
})
const dataStatus = useDataStatus()
const backtestDataStatus = assetType === 'etf'
? dataStatus.data?.etf_enriched
: dataStatus.data?.enriched
const earliestDate = backtestDataStatus?.earliest_date ?? null
const backtestDataUnavailable = dataStatus.isSuccess && !earliestDate
const backtestDataLabel = assetType === 'etf' ? 'ETF 指标数据' : '股票指标数据'
const resetConfigFromDetail = (detail: StrategyDetail) => {
setStrategyParams(strategyDefaultParams(detail))
setOverrides(buildDefaultOverrides(detail))
}
// 「应用到策略」: 把弹窗里当前编辑的 overrides + params 持久化为策略定义,
// 使所有页面加载该策略时都用这些参数(后端 save_config → 落盘 strategy_overrides/{id}.json)。
const [applying, setApplying] = useState(false)
const handleApplyToStrategy = async () => {
if (!detail || !selectedStrategy) return
setApplying(true)
try {
// 合并 params 进 overrides(后端 _strategy_detail 会把 params 合并进 params_defaults)
const payload = { ...normalizeStrategyOverrides(detail, overrides), params: strategyParams }
await api.strategySaveConfig(selectedStrategy, payload)
toast('已应用到策略定义', 'success')
await strategyDetail.refetch()
setSettingsOpen(false)
} catch (e) {
toast(`应用失败 · ${String((e as Error)?.message || e)}`, 'error')
} finally {
setApplying(false)
}
}
// 刷新页面后: 从 localStorage 恢复未完成的回测任务
useEffect(() => {
tryReconnect()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const detail = strategyDetail.data
if (!detail) return
const configSignature = strategyBacktestConfigSignature(detail)
const configKey = `${assetType}:${detail.id}:${configSignature}`
if (loadedStrategyRef.current === configKey) return
loadedStrategyRef.current = configKey
if (
saved?.assetType === assetType
&& saved.selectedStrategy === detail.id
&& saved.strategyConfigSignature === configSignature
&& (saved.params || saved.overrides)
) {
setStrategyParams(mergeStrategyParams(detail, saved.params))
setOverrides(normalizeStrategyOverrides(detail, saved.overrides ?? buildDefaultOverrides(detail)))
return
}
resetConfigFromDetail(detail)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assetType, strategyDetail.data])
// 当全局回测任务完成时, 把结果写入组件 (切页回来也能恢复)
useEffect(() => {
if (backtestTask && !backtestTask.isPending && backtestTask.result) {
setResult(backtestTask.result)
setResultTab('daily')
setDailyPage(0)
setTradePage(0)
storage.strategyBacktestLast.set({
selectedStrategy,
symbols,
assetType,
start,
end,
matching,
entryFill,
exitFill,
fees,
stampTax,
slippage,
maxPositions,
maxExposure,
initialCapital,
positionSizing,
mode: simMode,
holdingDays,
minuteFill: isMinuteStrategy ? false : highGranularity,
regimeStates,
regimeMinScore,
params: strategyParams,
overrides,
strategyConfigSignature: strategyDetail.data
? strategyBacktestConfigSignature(strategyDetail.data)
: undefined,
result: backtestTask.result,
})
}
}, [backtestTask])
const handleRun = () => {
if (!selectedStrategy || backtestDataUnavailable) return
const requestOverrides = detail
? normalizeStrategyOverrides(detail, overrides)
: overrides
startBacktest({
strategy_id: selectedStrategy,
asset_type: assetType,
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
start: start || null,
end: end || undefined,
matching,
entry_fill: entryFill,
exit_fill: exitFill,
commission_pct: Number(fees) / 10000,
stamp_tax_pct: Number(stampTax) / 1000,
slippage_bps: Number(slippage),
max_positions: Number(maxPositions),
max_exposure_pct: Number(maxExposure) / 100,
initial_capital: Number(initialCapital),
position_sizing: positionSizing,
params: strategyParams,
overrides: requestOverrides,
mode: simMode,
holding_days: Number(holdingDays) || 5,
minute_fill: isMinuteStrategy ? false : highGranularity,
regime_filter: regimeStates.length > 0 || regimeMinScore !== ''
? {
...(regimeStates.length > 0 ? { states: regimeStates } : {}),
...(regimeMinScore !== '' ? { min_score: Number(regimeMinScore) } : {}),
}
: null,
})
}
// 提取统计
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<Pick<QuickRangeConfig, 'enabled' | 'unit' | 'value'>>) => {
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<DailyTradeRow[]>(() => {
const rows = new Map<string, Omit<DailyTradeRow, 'cumulativePnl'>>()
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<string, string> = {}
result?.trades.forEach(t => {
if (t.name) names[t.symbol] = t.name
})
return names
}, [result?.trades])
const detail = strategyDetail.data
const matrixStrategy = detail?.execution_backend === 'matrix_native'
const compositeStrategy = detail?.source === 'composite'
const visibleAdvancedTabs = useMemo(
() => matrixStrategy
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit')
: compositeStrategy
// composite 的 entry/exit/scoring 由子策略决定, composite 层只调合并参数(params Tab)
? ADVANCED_TABS.filter(tab => tab.id !== 'entry' && tab.id !== 'exit' && tab.id !== 'scoring')
: ADVANCED_TABS,
[matrixStrategy, compositeStrategy],
)
const basicFilter = (overrides.basic_filter ?? {}) as Record<string, any>
const entrySignals = (overrides.entry_signals ?? []) as string[]
const exitSignals = (overrides.exit_signals ?? []) as string[]
const effectiveExitSignals = (overrides.exit_signals ?? detail?.exit_signals ?? []) as string[]
const minuteTriggerSignals = detail?.minute_exit_trigger_supported_signals ?? []
const unsupportedMinuteExitSignals = effectiveExitSignals.filter(signal => !minuteTriggerSignals.includes(signal))
const minuteExitTriggerSupported = effectiveExitSignals.length > 0 && unsupportedMinuteExitSignals.length === 0
// 分钟策略: 入场在盘中触发分钟成交, 日线专属的成交口径选项不适用
const isMinuteStrategy = detail?.execution_backend === 'minute_filter'
const { data: minuteDataStatus } = useQuery({
queryKey: QK.dataStatus,
queryFn: api.dataStatus,
enabled: isMinuteStrategy,
staleTime: 60_000,
})
// 分钟回测窗口守卫: 开始日期早于本地分钟K起点会被后端拒绝, 前置警示
const minuteEarliest = minuteDataStatus?.minute?.earliest_date
const minuteStartMismatch = isMinuteStrategy && !!minuteEarliest && start < minuteEarliest
useEffect(() => {
if (highGranularity && minuteExitTriggerSupported && !isMinuteStrategy) return
if (exitFill === 'signal_next_minute') setExitFill('close_t')
}, [exitFill, highGranularity, minuteExitTriggerSupported, isMinuteStrategy])
const scoring = useMemo(() => (overrides.scoring ?? {}) as Record<string, number>, [overrides.scoring])
const scoringDirections = useMemo(
() => (overrides.scoring_directions ?? {}) as Record<string, ScoringDirection>,
[overrides.scoring_directions],
)
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(round4(Math.abs(Number(overrides.stop_loss)) * 100))
const takeProfitPct = overrides.take_profit == null ? '' : String(round4(Math.abs(Number(overrides.take_profit)) * 100))
const trailingStopPct = overrides.trailing_stop == null ? '' : String(round4(Math.abs(Number(overrides.trailing_stop)) * 100))
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(round4(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100))
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(round4(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 (matrixStrategy && (settingsTab === 'entry' || settingsTab === 'exit')) {
setSettingsTab('params')
}
}, [matrixStrategy, settingsTab])
const updateOverride = (key: string, value: any) => {
setOverrides(prev => ({ ...prev, [key]: value }))
}
const updateBasicFilter = (key: string, value: any) => {
updateOverride('basic_filter', { ...basicFilter, [key]: value })
}
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}%` : '止损未设',
takeProfitPct !== '' ? `止盈 ${takeProfitPct}%` : '止盈未设',
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 resultRegimeFilter = result?.config?.regime_filter as {
states?: string[]
min_score?: number
} | null | undefined
const resultRegimeSummary = resultRegimeFilter
? [
resultRegimeFilter.states?.length
? resultRegimeFilter.states.map(state => REGIME_STATE_LABELS[state as keyof typeof REGIME_STATE_LABELS] ?? state).join('/')
: null,
resultRegimeFilter.min_score != null ? `最低 ${resultRegimeFilter.min_score} 分` : null,
].filter(Boolean).join(' · ')
: ''
const selectionStats = result?.stats?.selection as Record<string, number | boolean> | undefined
const selectionStages = selectionStats
? [
{
key: 'strategy',
label: result?.stats?.execution_backend === 'matrix_native' ? '策略信号' : '策略命中',
value: Number(selectionStats.strategy_matches ?? 0),
},
...(selectionStats.entry_trigger_enabled === true
? [{ key: 'entry', label: '入场候选', value: Number(selectionStats.entry_candidates ?? 0) }]
: []),
{ key: 'trades', label: '完成交易', value: Number(result?.stats?.n_trades ?? result?.trades.length ?? 0) },
]
: []
const executionStats = (result?.stats?.execution ?? {}) as Record<string, number>
const executionSummary = [
['buy_no_slot', '满仓未买'],
['buy_exposure', '仓位上限'],
['buy_score_filter', '评分过滤'],
['buy_limit_up', '涨停未买'],
['buy_suspended', '停牌未买'],
['sell_limit_down', '跌停阻塞'],
['sell_suspended', '停牌阻塞'],
['pending_exit', '待卖阻塞'],
['sell_minute_trigger_fallback', '分钟信号顺延'],
]
.map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) }))
.filter(item => item.value > 0)
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>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-medium text-secondary">选择策略</label>
{/* 分钟K成交 — 日线策略专属 (分钟策略入场天然按触发分钟成交) */}
{!isMinuteStrategy && (
<div className="flex items-center gap-1">
<Gauge className={`h-3 w-3 ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`} />
<button
onClick={toggleMinuteFill}
disabled={!hasMinuteBatch}
title={!hasMinuteBatch
? '分钟K成交价:分钟K(批量)数据不可用'
: '分钟K成交:细化成交价,并为兼容的卖出信号提供下一分钟成交。'
}
className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
!hasMinuteBatch ? 'bg-elevated opacity-50 cursor-not-allowed'
: highGranularity ? 'bg-amber-500 cursor-pointer'
: 'bg-elevated cursor-pointer'
}`}
>
<span className={`inline-block h-2.5 w-2.5 rounded-full bg-white shadow-sm transition-transform duration-200 ${
highGranularity ? 'translate-x-[13px]' : 'translate-x-0.5'
}`} />
</button>
<span className={`text-[9px] font-medium ${highGranularity ? 'text-amber-400' : 'text-muted/50'}`}>分钟成交</span>
{!hasMinuteBatch && (
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">分钟K</span>
)}
</div>
)}
</div>
{/* 分钟策略提示条: 数据窗口 + 成交语义 */}
{isMinuteStrategy && (
<div className="mb-2 flex items-start gap-1.5 rounded-btn border border-sky-500/30 bg-sky-500/5 px-2 py-1.5">
<Clock className="h-3 w-3 text-sky-400 shrink-0 mt-px" />
<div className="text-[10px] leading-snug text-sky-400/90">
<span className="font-medium">分钟策略回测</span>
:逐日回放分钟K,信号分钟收盘价买入;日线条件按 T-1 完成态评估。
{minuteDataStatus?.minute?.earliest_date
? ` 本地分钟K ${minuteDataStatus.minute.earliest_date} ~ ${minuteDataStatus.minute.latest_date}${minuteDataStatus.minute.trading_days} 个交易日),缺分区的日子自动跳过。`
: ' 本地暂无分钟K数据,请先在数据页拉取。'}
{minuteStartMismatch && (
<span className="mt-0.5 block text-amber-400">
当前开始日期 {start} 早于分钟数据起点 {minuteEarliest},运行会被拒绝 请把开始日期调整到 {minuteEarliest} 之后,或先用「扩展分钟K历史」拉取。
</span>
)}
</div>
</div>
)}
{/* 分钟K开启时的提示条 */}
{highGranularity && hasMinuteBatch && !isMinuteStrategy && (
<div className="mb-2 flex items-start gap-1.5 rounded-btn border border-amber-400/30 bg-amber-400/5 px-2 py-1.5">
<Zap className="h-3 w-3 text-amber-400 shrink-0 mt-px" />
<div className="text-[10px] leading-snug text-amber-400/90">
<span className="font-medium">分钟K成交价</span>
:默认在成交日细化穿越价/VWAP;选择“信号触发卖出”时,会对兼容的卖出信号做分钟回放。需本地有足够的分钟K历史
</div>
</div>
)}
<div className="overflow-hidden rounded-input border border-border bg-surface">
<div className="flex border-b border-border/60 bg-base/30 p-0.5">
{STRATEGY_GROUPS.map(group => (
<button
key={group.id}
type="button"
onClick={() => setStrategyGroup(group.id)}
className={`flex-1 rounded-[6px] px-1.5 py-1 text-[10px] font-medium transition-colors ${strategyGroup === group.id
? 'bg-accent/15 text-accent shadow-sm'
: 'text-muted hover:bg-elevated/70 hover:text-secondary'
}`}
>
{group.label}
</button>
))}
</div>
<div className="flex max-h-[128px] flex-wrap gap-1 overflow-y-auto p-1">
{strategies.isLoading && (
<span className="text-xs text-muted px-2 py-1">加载中…</span>
)}
{!strategies.isLoading && filteredStrategyList.length === 0 && (
<span className="text-xs text-muted px-2 py-1">当前分组暂无策略</span>
)}
{filteredStrategyList.map(st => (
<button
key={st.id}
onClick={() => setSelectedStrategy(st.id)}
className={`px-2 py-1 rounded-btn text-[11px] border transition-all duration-150 ease-smooth cursor-pointer
${selectedStrategy === st.id
? 'border-accent/50 bg-accent/10 text-accent shadow-[0_0_10px_rgba(59,130,246,0.1)]'
: 'border-border bg-base text-secondary hover:border-accent/40'
}`}
>
<span className="font-medium">{st.name}</span>
{st.timeframes?.includes('1m') && (
<span className="ml-1 text-[8px] px-1 py-px rounded border border-sky-500/30 bg-sky-500/10 text-sky-400">分钟</span>
)}
{st.source && st.source !== 'builtin' && (
<span className={`ml-1 text-[8px] px-1 py-px rounded border ${BADGE_CLS_MAP[st.source] ?? ''}`}>
{SRC_MAP[st.source] ?? ''}
</span>
)}
</button>
))}
</div>
</div>
</div>
{selectedStrategy && strategyDetail.isLoading && (
<div className="rounded-btn border border-border bg-surface px-2.5 py-2 text-xs text-muted">加载策略配置…</div>
)}
<button
type="button"
onClick={() => detail && setSettingsOpen(true)}
disabled={!detail || strategyDetail.isLoading}
className="group w-full rounded-btn border border-border bg-surface px-3 py-2.5 text-left transition-colors hover:border-accent/40 hover:bg-elevated/70 disabled:cursor-not-allowed disabled:opacity-55"
>
<span className="flex items-center gap-2 text-xs font-semibold text-foreground">
<SlidersHorizontal className="h-3.5 w-3.5 text-accent" />
策略设置
<span className="ml-auto text-[10px] font-normal text-muted group-hover:text-accent">编辑</span>
</span>
<span className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-secondary">
<span className="truncate">{selectedStrategyName}</span>
{selectedStrategySource && (
<span className={`shrink-0 text-[8px] px-1 py-px rounded border ${BADGE_CLS_MAP[selectedStrategySource] ?? ''}`}>
{SRC_MAP[selectedStrategySource] ?? selectedStrategySource}
</span>
)}
</span>
<span className="mt-1 block text-[10px] font-medium text-secondary">{stockPoolSummary}</span>
<span className="mt-1 block text-[10px] leading-4 text-muted">{advancedSummary}</span>
</button>
<div className="rounded-btn border border-border bg-surface p-2.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<div className="text-xs font-medium text-foreground">回测区间</div>
<WarmupBadge />
</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 items-center gap-1">
<div className="flex min-w-0 flex-1 rounded-input bg-base/60 p-0.5">
{visibleQuickRanges.map(range => (
<button
key={range.id}
type="button"
onClick={() => applyQuickRange(range)}
className={`${rangeButtonCls(range.id)} flex-1`}
>
{quickRangeLabel(range)}
</button>
))}
</div>
<button
type="button"
onClick={() => setRangeSettingsOpen(v => !v)}
title="设置快捷区间"
aria-label="设置快捷区间"
className={`shrink-0 rounded-btn border px-2 py-1.5 transition-colors ${rangeSettingsOpen
? 'border-accent/40 bg-accent/10 text-accent'
: 'border-border bg-base text-secondary hover:border-accent/40 hover:text-accent'
}`}
>
<SlidersHorizontal className="h-3.5 w-3.5" />
</button>
</div>
{rangeSettingsOpen && (
<div className="mt-2 rounded-input border border-border/60 bg-base/50 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2 text-[10px] text-muted">
<span>快捷区间</span>
<span> 1-120 / 1-10</span>
</div>
<div className="space-y-1.5">
{quickRanges.map((range, index) => {
const limits = range.unit === 'all' ? null : QUICK_RANGE_LIMITS[range.unit]
return (
<div key={range.id} className="grid grid-cols-[3rem_1fr_4.5rem] items-center gap-1.5">
<label className="flex items-center gap-1 text-[11px] text-secondary">
<input
type="checkbox"
checked={range.enabled}
onChange={e => updateQuickRange(range.id, { enabled: e.target.checked })}
className="h-3 w-3 accent-accent"
/>
{index + 1}
</label>
<select
value={range.unit}
onChange={e => updateQuickRange(range.id, { unit: e.target.value as QuickRangeUnit })}
className={INPUT_CLS}
>
<option value="month"></option>
<option value="year"></option>
<option value="all">全部</option>
</select>
<input
type="number"
min={limits?.min}
max={limits?.max}
disabled={range.unit === 'all'}
value={range.unit === 'all' ? '' : range.value}
onChange={e => updateQuickRange(range.id, { value: Number(e.target.value) })}
placeholder="—"
className={`${INPUT_CLS} ${range.unit === 'all' ? 'opacity-50' : ''}`}
/>
</div>
)
})}
</div>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="mb-1.5 flex items-center gap-1">
<label className="text-xs font-medium text-secondary">建仓口径</label>
<FillRuleHint />
</div>
{isMinuteStrategy ? (
<div className={`${INPUT_CLS} flex items-center gap-1.5`} title="信号在盘中触发分钟成交,无次日开盘口径">
<Clock className="h-3 w-3 text-sky-400 shrink-0" />
<span className="text-secondary">信号分钟收盘</span>
<span className="text-[8px] px-1 py-px rounded border border-sky-500/30 bg-sky-500/10 text-sky-400">分钟</span>
</div>
) : (
<select value={entryFill} onChange={e => setEntryFill(e.target.value as 'close_t' | 'open_t+1')} className={INPUT_CLS}>
<option value="open_t+1">次日开盘(推荐)</option>
<option value="close_t">信号日收盘</option>
</select>
)}
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-secondary">清仓口径</label>
<select
value={exitFill}
onChange={e => setExitFill(e.target.value as 'close_t' | 'open_t+1' | 'signal_next_minute')}
className={INPUT_CLS}
>
<option value="close_t">信号日收盘(推荐)</option>
<option value="open_t+1">次日开盘</option>
{highGranularity && minuteExitTriggerSupported && !isMinuteStrategy && (
<option value="signal_next_minute">信号触发卖出 BETA</option>
)}
</select>
</div>
{!isMinuteStrategy && (entryFill === 'close_t' || exitFill === 'close_t') && (
<div className="col-span-2 flex items-start gap-1 text-[10px] leading-4 text-warning">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>信号日收盘仅适合收盘前已确认的信号</span>
</div>
)}
{exitFill === 'signal_next_minute' && (
<div className="col-span-2 text-[10px] leading-4 text-accent">
分钟收盘确认卖出信号后,按下一分钟开盘成交;尾盘或分钟数据缺失时顺延到下一交易日开盘
</div>
)}
{highGranularity && effectiveExitSignals.length > 0 && !minuteExitTriggerSupported && (
<div className="col-span-2 flex items-start gap-1 text-[10px] leading-4 text-muted">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>当前卖出信号暂不支持分钟触发回放</span>
</div>
)}
</div>
{simMode === 'position' && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">初始资金</label>
<input type="number" value={initialCapital} onChange={e => setInitialCapital(e.target.value)}
className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">买入权重</label>
<select value={positionSizing} onChange={e => setPositionSizing(e.target.value as any)} className={INPUT_CLS}>
<option value="equal">等权买入</option>
<option value="score_weight">评分加权</option>
</select>
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">最大持仓数</label>
<input type="number" value={maxPositions} onChange={e => setMaxPositions(e.target.value)}
className={INPUT_CLS} />
</div>
<div>
<label className="text-xs font-medium text-secondary block mb-1.5">最大总仓位(%)</label>
<input type="number" min={0} max={100} value={maxExposure} onChange={e => setMaxExposure(e.target.value)}
className={INPUT_CLS} />
</div>
</div>
)}
{simMode === 'position' && (
<div className="grid grid-cols-3 gap-2">
<div>
<label className="text-[10px] font-medium text-secondary block mb-1">佣金 </label>
<input type="number" min={0} value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-[10px] font-medium text-secondary block mb-1">印花税 </label>
<input type="number" min={0} value={stampTax} onChange={e => setStampTax(e.target.value)} className={INPUT_CLS} />
</div>
<div>
<label className="text-[10px] font-medium text-secondary block mb-1">滑点 </label>
<input type="number" min={0} value={slippage} onChange={e => setSlippage(e.target.value)} className={INPUT_CLS} />
</div>
</div>
)}
{simMode === 'position' && (
<div className="text-[10px] leading-4 text-muted">
单票目标约 {Number.isFinite(targetPositionPct) ? targetPositionPct.toFixed(1) : '—'}%。最大总仓位控制资金投入;剩余现金不是新增持仓名额,只有实际卖出成功才释放持仓数。
</div>
)}
{simMode === 'full' && (
<div className="rounded-btn border border-accent/20 bg-accent/5 px-3 py-2.5 text-[11px] leading-relaxed text-secondary">
<span className="font-medium text-foreground">全量模拟</span>:每日将策略选出的全部候选独立买入,不受资金/最大持仓数限制;每一笔仍按策略卖点、止损、移动止盈/止损和最长持仓执行,用于评估策略本身的选股 + 交易规则质量。
</div>
)}
{backtestDataUnavailable && (
<div className="flex items-start gap-1.5 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2 text-[11px] leading-4 text-danger">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>缺少{backtestDataLabel},请先在数据页面同步日K并完成指标计算</span>
</div>
)}
{isPending ? (
<button
onClick={stopBacktest}
className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
bg-danger/15 border border-danger/40 text-sm font-medium text-danger hover:bg-danger/25
transition-colors duration-150 ease-smooth"
>
<Square className="h-3.5 w-3.5 fill-current" />
停止回测
</button>
) : (
<button
onClick={handleRun}
disabled={!selectedStrategy || strategyDetail.isLoading || backtestDataUnavailable}
className="group w-full inline-flex items-center justify-center gap-2.5 rounded-btn border border-accent/40
bg-gradient-to-r from-accent to-blue-500 px-3 py-2.5 text-white shadow-[0_10px_24px_rgba(59,130,246,0.22)]
transition-all duration-150 ease-smooth hover:-translate-y-0.5 hover:shadow-[0_14px_28px_rgba(59,130,246,0.28)]
disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none"
>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/18 ring-1 ring-white/25 transition-transform group-hover:scale-105">
<Play className="h-3.5 w-3.5 translate-x-px fill-current" />
</span>
<span className="text-sm font-semibold tracking-wide">运行回测</span>
</button>
)}
</section>
{/* 结果面板 */}
<section className="min-w-0 space-y-3 bg-base/15 px-3 py-3 xl:overflow-y-auto">
{/* 模式切换: 仓位模拟 / 全量模拟 */}
<div className="flex items-center justify-between gap-2">
<div className="inline-flex rounded-btn border border-border bg-surface/80 p-0.5 shadow-sm">
{([['position', '仓位模拟'], ['full', '全量模拟']] as const).map(([val, label]) => (
<button
key={val}
onClick={() => setSimMode(val)}
className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
simMode === val
? 'bg-accent text-white shadow-sm'
: 'text-secondary hover:bg-elevated hover:text-foreground'
}`}
title={val === 'position' ? '受仓位/资金约束的真实账户模拟' : '全部候选独立执行,不受资金和持仓数量约束'}
>
{val === 'position' ? <Play className="h-3.5 w-3.5" /> : <BarChart3 className="h-3.5 w-3.5" />}
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
{simMode === 'full' && (
maxHoldDaysValue !== '' ? (
<div className="rounded-btn border border-border bg-surface px-2 py-1 text-[11px] text-secondary">
策略最长 <span className="font-mono text-foreground">{maxHoldDaysValue}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-[11px] text-secondary">
<span>兜底上限</span>
<div className="flex rounded-btn border border-border overflow-hidden">
{(['1', '5', '10', '20'] as const).map(d => (
<button
key={d}
onClick={() => setHoldingDays(d)}
className={`px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
holdingDays === d
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated'
}`}
>
{d}
</button>
))}
</div>
</div>
)
)}
{result && !result.error && (
<button
type="button"
onClick={() => saveCandidate.mutate()}
disabled={saveCandidate.isPending}
className="inline-flex h-8 items-center gap-1.5 rounded-btn border border-border bg-surface px-2.5 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:opacity-50"
>
<BookmarkPlus className="h-3.5 w-3.5" />
{saveCandidate.isPending ? '保存中' : '保存候选'}
</button>
)}
</div>
</div>
{/* 市场环境过滤: 只在指定环境的交易日入场(强制 T-1, 用前一日环境判定) */}
<div className="rounded-btn border border-border bg-surface/50 px-3 py-2 space-y-1.5">
<div className="flex items-center gap-2">
<Gauge className="h-3.5 w-3.5 text-accent" />
<span className="text-xs font-medium text-foreground">环境过滤</span>
<span className="text-[10px] text-muted">仅在前一日环境满足时入场(防未来函数)</span>
<div className="ml-auto flex items-center gap-1">
<span className="text-[10px] text-muted">最低分</span>
<input type="number" min={0} max={100} value={regimeMinScore} placeholder="不限"
onChange={e => setRegimeMinScore(e.target.value ? Number(e.target.value) : '')}
className="w-14 h-6 px-1 rounded border border-border bg-base text-[11px] text-foreground text-center focus:outline-none focus:border-accent/50" />
</div>
</div>
<div className="flex flex-wrap gap-1">
{(Object.keys(REGIME_STATE_LABELS) as (keyof typeof REGIME_STATE_LABELS)[]).map(s => {
const active = regimeStates.includes(s)
return (
<button key={s} onClick={() => setRegimeStates(prev => active ? prev.filter(x => x !== s) : [...prev, s])}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[11px] transition-colors cursor-pointer ${
active ? 'border-transparent text-white' : 'border-border text-muted hover:text-secondary'
}`}
style={active ? { backgroundColor: REGIME_STATE_COLORS[s] } : undefined}>
<span className="inline-block h-2 w-2 rounded-sm" style={{ backgroundColor: active ? '#fff' : REGIME_STATE_COLORS[s] }} />
{REGIME_STATE_LABELS[s]}
</button>
)
})}
{(regimeStates.length > 0 || regimeMinScore !== '') && (
<button onClick={() => { setRegimeStates([]); setRegimeMinScore('') }}
className="text-[10px] text-muted hover:text-danger px-1">清除</button>
)}
</div>
</div>
{result?.error && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
{result.error}
</div>
)}
{backtestTask?.error && (
<div className="text-sm text-danger bg-danger/10 border border-danger/30 rounded-btn px-3 py-2">
<div>{backtestTask.error}</div>
{result && (
<div className="mt-1 text-xs text-secondary">本次回测未生成新结果,下方仍展示上一次成功结果。</div>
)}
</div>
)}
{!result && !isPending && (
<EmptyState
icon={FlaskConical}
title="选择策略并开始回测"
hint="策略回测复用策略定义 ( 买入/卖出触发器、止损、最大持仓 ) 做全周期模拟。服务器建议优先使用最近3个月;长周期建议本机或 8GB 以上内存环境运行。"
/>
)}
{isPending && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-card border border-accent/40 bg-accent/10 px-4 py-2.5"
>
<div className="flex items-center gap-2.5">
<span className="relative flex h-4 w-4 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/50" />
<Loader2 className="relative h-4 w-4 animate-spin text-accent" />
</span>
<div className="min-w-0">
<div className={backtestTask?.reconnecting ? 'text-xs font-medium text-warning' : 'text-xs font-medium text-accent'}>
{backtestTask?.reconnecting
? '连接中断,重试中…'
: backtestTask?.progress
? `回测中 · 第 ${backtestTask.progress.day}/${backtestTask.progress.total} 天 (${backtestTask.progress.date})`
: '正在重新计算回测…'}
</div>
<div className="mt-0.5 text-[11px] text-secondary">
{backtestTask?.reconnecting
? '正在尝试恢复连接,若持续失败可停止后重试'
: result ? '当前展示上次结果,完成后自动替换' : '正在加载回测数据…'}
</div>
</div>
{backtestTask?.progress && (
<span className="ml-auto shrink-0 font-mono text-sm font-semibold text-accent">
{((backtestTask.progress.day / backtestTask.progress.total) * 100).toFixed(0)}%
</span>
)}
<button
type="button"
onClick={stopBacktest}
className="inline-flex shrink-0 items-center gap-1 rounded-btn border border-danger/40 bg-danger/10 px-2 py-1 text-[11px] text-danger transition-colors hover:bg-danger/20"
>
<Square className="h-3 w-3 fill-current" />
停止
</button>
</div>
{backtestTask?.progress && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-base/60">
<div
className="h-full rounded-full bg-accent transition-all duration-300 ease-out"
style={{ width: `${(backtestTask.progress.day / backtestTask.progress.total) * 100}%` }}
/>
</div>
)}
</motion.div>
)}
{/* 旧全量模拟结果: 固定前瞻收益统计 (兼容历史缓存结果) */}
{result && !result.error && result.stats && result.stats.mode === 'full' && result.stats.full_kind !== 'candidate_execution' && (
<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"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-foreground">{result.strategy_info?.name ?? '策略'}</span>
<span className="text-[10px] px-1 py-px rounded border border-accent/30 bg-accent/10 text-accent">全量模拟</span>
{resultRegimeSummary && (
<span className="inline-flex items-center gap-1 rounded border border-accent/25 bg-accent/10 px-1.5 py-px text-[10px] text-accent">
<Gauge className="h-2.5 w-2.5" />
环境 {resultRegimeSummary}
</span>
)}
<span className="text-[10px] text-secondary">持有 {result.config?.holding_days ?? 5} </span>
<span className="ml-auto text-[11px] text-muted font-mono">
{String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)}
</span>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Stat label={<MetricLabel label="平均收益" metric="avgReturn" />} value={fmtPct(result.stats.avg_return)} color={statValueColor(result.stats.avg_return)} />
<Stat label={<MetricLabel label="中位数" metric="medianReturn" />} value={fmtPct(result.stats.median_return)} color={statValueColor(result.stats.median_return)} />
<Stat label={<MetricLabel label="胜率" metric="winRate" />} value={fmtPct(result.stats.win_rate)} color={statValueColor(result.stats.win_rate)} />
<Stat label={<MetricLabel label="盈亏比" metric="profitFactor" />} value={result.stats.profit_factor != null ? Number(result.stats.profit_factor).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="超额(vs基准)" metric="excessReturn" />} value={fmtPct(result.stats.excess)} color={statValueColor(result.stats.excess)} />
<Stat label={<MetricLabel label="夏普" metric="sharpe" />} value={result.stats.sharpe != null ? Number(result.stats.sharpe).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="最大回撤" metric="maxDrawdown" />} value={fmtPct(result.stats.max_drawdown)} color={statValueColor(result.stats.max_drawdown)} />
<Stat label={<MetricLabel label="累计收益" metric="totalReturn" />} value={fmtPct(result.stats.total_return)} color={statValueColor(result.stats.total_return)} />
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-muted">
<span>候选样本 <b className="text-foreground num">{result.stats.n_candidates ?? 0}</b> (标的×信号日)</span>
<span>信号天数 <b className="text-foreground num">{result.stats.n_days ?? 0}</b></span>
<span>日均候选 <b className="text-foreground num">{result.stats.avg_daily_candidates ?? 0}</b></span>
<span>最佳 <b className="text-bull num">{fmtPct(result.stats.best)}</b></span>
<span>最差 <b className="text-bear num">{fmtPct(result.stats.worst)}</b></span>
<span>基准(上证) <b className="text-foreground num">{fmtPct(result.stats.benchmark_return)}</b></span>
</div>
{/* 累计超额曲线 (复用 StrategyNavChart) */}
{result.equity_curve.length > 1 && (
<div className="rounded-card border border-border p-3">
<div className="mb-2 text-xs font-medium text-secondary">累计收益曲线(日均复利)</div>
<StrategyNavChart result={result} />
</div>
)}
{/* 收益分布直方图 */}
{Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
<div className="rounded-card border border-border p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-secondary">候选标的收益分布(持有 {result.config?.holding_days ?? 5} )</span>
<span className="text-[10px] text-muted">=正收益 · 绿=负收益</span>
</div>
<ReturnDistributionChart distribution={result.stats.return_distribution} />
</div>
)}
<div className="text-[11px] text-muted">run_id: {result.run_id}</div>
</motion.div>
)}
{result && !result.error && result.stats && !result.stats.error && (result.stats.mode !== 'full' || result.stats.full_kind === 'candidate_execution') && (
<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"
>
{/* 策略信息 */}
{result.strategy_info && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground">{result.strategy_info.name}</span>
{result.stats.full_kind === 'candidate_execution' && (
<span className="text-[9px] px-1 py-px rounded border border-accent/30 bg-accent/10 text-accent">全量独立执行</span>
)}
{result.strategy_info.source && (
<span className={`text-[9px] px-1 py-px rounded border ${BADGE_CLS_MAP[result.strategy_info.source] ?? ''}`}>
{SRC_MAP[result.strategy_info.source] ?? ''}
</span>
)}
{resultRegimeSummary && (
<span className="inline-flex items-center gap-1 rounded border border-accent/25 bg-accent/10 px-1.5 py-px text-[9px] text-accent">
<Gauge className="h-2.5 w-2.5" />
环境 {resultRegimeSummary}
</span>
)}
</div>
{/* 叠加策略: 子策略构成归因 */}
{result.strategy_info.composite_children && result.strategy_info.composite_children.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<Layers className="h-3 w-3 text-teal-400 shrink-0" />
<span className="text-[10px] text-muted">叠加 {result.strategy_info.composite_children.length} 策略</span>
{result.strategy_info.composite_children.map(c => (
<span key={c.id} className="text-[9px] px-1.5 py-px rounded border border-teal-500/25 bg-teal-500/10 text-teal-400">
{c.id}<span className="text-teal-400/60 ml-0.5">{(c.weight * 100).toFixed(0)}%</span>
</span>
))}
</div>
)}
{result.strategy_info.stop_loss != null && (
<span className="text-[10px] text-secondary">止损 {fmtPct(result.strategy_info.stop_loss)}</span>
)}
{result.strategy_info.take_profit != null && (
<span className="text-[10px] text-secondary">止盈 {fmtPct(result.strategy_info.take_profit)}</span>
)}
{result.strategy_info.trailing_stop != null && (
<span className="text-[10px] text-secondary">移损 {fmtPct(result.strategy_info.trailing_stop)}</span>
)}
{result.strategy_info.trailing_take_profit_activate != null && result.strategy_info.trailing_take_profit_drawdown != null && (
<span className="text-[10px] text-secondary">回撤 {fmtPct(result.strategy_info.trailing_take_profit_activate)}-{fmtPct(result.strategy_info.trailing_take_profit_drawdown)}</span>
)}
{result.strategy_info.max_hold_days != null && (
<span className="text-[10px] text-secondary">最长 {result.strategy_info.max_hold_days} </span>
)}
{resultTradeDays > 0 && (
<span className="ml-auto flex items-center gap-2 text-[11px] text-muted">
<span className="font-mono">{String(resultStartDate).slice(0, 10)} ~ {String(resultEndDate).slice(0, 10)}</span>
<span>{resultTradeDays} </span>
</span>
)}
{result.elapsed_ms > 0 && (
<span className={`flex items-center gap-1 text-[11px] text-muted ${resultTradeDays > 0 ? '' : 'ml-auto'}`}>
<Clock className="h-3 w-3" />
<span>总耗时</span>
<span className="num">{fmtDuration(result.elapsed_ms)}</span>
</span>
)}
</div>
)}
{/* 统计卡片 */}
<div className="rounded-card border border-border bg-surface p-4">
<div className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-3">
<Stat label={<MetricLabel label="总收益" metric="totalReturn" />} value={strategyReturn != null ? fmtPct(strategyReturn) : '—'}
color={statValueColor(strategyReturn)} />
<Stat label={<MetricLabel label="年化" metric="annualReturn" />} value={pick('annual_return') != null ? fmtPct(pick('annual_return') as number) : '—'}
color={statValueColor(pick('annual_return') as number)} />
<Stat label={<MetricLabel label="同期上证" metric="benchmarkReturn" />} value={benchmarkReturn != null ? fmtPct(benchmarkReturn) : '—'}
color={statValueColor(benchmarkReturn)} />
<Stat label={<MetricLabel label="超额收益" metric="excessReturn" />} value={excessReturn != null ? fmtPct(excessReturn) : '—'}
color={statValueColor(excessReturn)} />
<Stat label={<MetricLabel label="夏普" metric="sharpe" />} value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="索提诺" metric="sortino" />} value={pick('sortino') != null ? Number(pick('sortino')).toFixed(2) : '—'} />
<Stat label={<MetricLabel label="最大回撤" metric="maxDrawdown" />} value={pick('max_drawdown') != null ? fmtPct(pick('max_drawdown') as number) : '—'}
color="#34d399" />
<Stat label={<MetricLabel label="蒙卡回撤(中位)" metric="mcDrawdownMedian" />} value={pick('mc_maxdd_p50') != null ? fmtPct(pick('mc_maxdd_p50') as number) : '—'}
color="#34d399" />
<Stat label={<MetricLabel label="蒙卡回撤(95%边界)" metric="mcDrawdown95" />} value={pick('mc_maxdd_p95') != null ? fmtPct(pick('mc_maxdd_p95') as number) : '—'}
color="#34d399" />
<Stat label={<MetricLabel label="胜率" metric="winRate" />} value={pick('win_rate') != null ? fmtPct(pick('win_rate') as number) : '—'} />
<Stat label={<MetricLabel label="交易数" metric="tradeCount" />} value={pick('n_trades') != null ? String(pick('n_trades')) : '—'} />
{result.stats.full_kind === 'candidate_execution' ? (
<Stat label={<MetricLabel label="平均持仓" metric="avgDuration" />} value={pick('avg_duration') != null ? `${Number(pick('avg_duration')).toFixed(1)}天` : '—'} />
) : (
<Stat label={<MetricLabel label="最终权益" metric="finalEquity" />} value={pick('final_equity') != null ? fmtPrice(pick('final_equity') as number) : '—'} />
)}
</div>
</div>
{selectionStages.length > 0 && (
<div className="flex flex-wrap items-center gap-y-2 rounded-card border border-border bg-base/35 px-3 py-2 text-[11px] text-secondary">
<span className="mr-2 font-medium text-foreground">选择漏斗</span>
{selectionStages.map((stage, index) => (
<div key={stage.key} className="flex items-center">
{index > 0 && <ChevronRight className="mx-1.5 h-3 w-3 text-muted/60" />}
<span>{stage.label} <b className="font-mono text-foreground">{stage.value}</b></span>
</div>
))}
{Number(selectionStats?.entry_trigger_filtered ?? 0) > 0 && (
<span className="ml-auto text-amber-400">入场触发器过滤 {Number(selectionStats?.entry_trigger_filtered)} </span>
)}
</div>
)}
{executionSummary.length > 0 && (
<div className="rounded-card border border-amber-400/25 bg-amber-400/5 px-3 py-2 text-[11px] leading-5 text-secondary">
<span className="font-medium text-amber-300">成交约束:</span>
{executionSummary.map((item, index) => (
<span key={item.key} className="ml-2">
{index > 0 ? '· ' : ''}{item.label} <span className="font-mono text-foreground">{item.value}</span>
</span>
))}
</div>
)}
{/* 净值曲线 */}
{result.equity_curve.length > 0 && (
<div className="rounded-card border border-border overflow-hidden">
<StrategyNavChart result={result} />
</div>
)}
{Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
<div className="rounded-card border border-border p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-secondary">独立候选交易收益分布</span>
<span className="text-[10px] text-muted">=正收益 · 绿=负收益</span>
</div>
<ReturnDistributionChart distribution={result.stats.return_distribution} />
</div>
)}
{/* Tab: 按日期 / 交易明细 / 选股分析 */}
{(result.trades.length > 0 || result.per_symbol_stats.length > 0) && (
<div className="rounded-card border border-border overflow-hidden">
<div className="flex items-center gap-1 border-b border-border px-4 pt-2">
{(['daily', 'trades', 'picks'] as const).map(t => (
<button
key={t}
onClick={() => setResultTab(t)}
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
resultTab === t
? 'border-accent text-accent'
: 'border-transparent text-secondary hover:text-foreground'
}`}
>
{t === 'daily'
? `每日交易 (${dailyTradeRows.length})`
: t === 'trades'
? `交易明细 (${sortedTrades.length})`
: `选股分析 (${result.per_symbol_stats.length})`}
</button>
))}
</div>
{resultTab === 'daily' && (
<div>
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm text-foreground">
<thead className="bg-elevated">
<tr className="text-left text-secondary">
<th className="px-3 py-2.5 font-medium w-[8.5rem]">日期</th>
<th className="px-3 py-2.5 font-medium">买入</th>
<th className="px-3 py-2.5 font-medium">卖出</th>
<th className="px-3 py-2.5 font-medium text-right w-[8rem]">当日收益</th>
<th className="px-3 py-2.5 font-medium text-right w-[8rem]">累计收益</th>
</tr>
</thead>
<tbody>
{visibleDailyRows.map(row => (
<tr key={row.date} className="border-t border-border hover:bg-elevated/50 transition-colors">
<td className="px-3 py-2.5 whitespace-nowrap">
<div className="font-mono text-foreground">{row.date}</div>
<div className="mt-0.5 text-[11px] text-muted">
{row.buys.length} / {row.sells.length}
</div>
</td>
<td className="px-3 py-2.5">
{row.buys.length === 0 ? (
<span className="text-muted"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{row.buys.map((t, i) => (
<DailyTradeChip key={`buy-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="buy" strategyName={result?.strategy_info?.name ?? selectedStrategyName} onClick={() => setSelectedTrade(t)} signalNames={signalNames} />
))}
</div>
)}
</td>
<td className="px-3 py-2.5">
{row.sells.length === 0 ? (
<span className="text-muted"></span>
) : (
<div className="flex flex-wrap gap-1.5">
{row.sells.map((t, i) => (
<DailyTradeChip key={`sell-${t.symbol}-${t.entry_date}-${t.exit_date}-${i}`} trade={t} side="sell" onClick={() => setSelectedTrade(t)} signalNames={signalNames} />
))}
</div>
)}
</td>
<td className={`px-3 py-2.5 text-right num font-semibold whitespace-nowrap ${priceColorClass(row.realizedPnl)}`}>
{fmtSignedMoney(row.realizedPnl)}
</td>
<td className={`px-3 py-2.5 text-right num font-semibold whitespace-nowrap ${priceColorClass(row.cumulativePnl)}`}>
{fmtSignedMoney(row.cumulativePnl)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{dailyTradeRows.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-2 text-xs text-muted">
<span>
显示 {dailyStart + 1}-{dailyEnd} / {dailyTradeRows.length} 天,每页 10
</span>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDailyPage(p => Math.max(0, p - 1))}
disabled={safeDailyPage <= 0}
className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
>
上一页
</button>
<span className="num text-secondary">
{safeDailyPage + 1} / {dailyPageCount}
</span>
<button
type="button"
onClick={() => setDailyPage(p => Math.min(dailyPageCount - 1, p + 1))}
disabled={safeDailyPage >= dailyPageCount - 1}
className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
>
下一页
</button>
</div>
</div>
)}
</div>
)}
{resultTab === 'trades' && (
<div className="overflow-x-auto">
<table className="w-full min-w-[960px] text-sm text-foreground">
<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">买入</th>
<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">原因</th>
</tr>
</thead>
<tbody>
{visibleTrades.map((t: StrategyBacktestTrade, i: number) => (
<tr key={`${t.symbol}-${t.entry_date}-${tradeStart + i}`} className="border-t border-border hover:bg-elevated/50 transition-colors group">
<td className="px-4 py-2.5">
<div className="font-medium text-foreground group-hover:text-accent transition-colors">
{t.name || t.symbol}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted">{t.symbol}</div>
</td>
<td className="px-4 py-2.5">
<TradeLegCell trade={t} side="buy" signalNames={signalNames} />
</td>
<td className="px-4 py-2.5">
<TradeLegCell trade={t} side="sell" signalNames={signalNames} />
</td>
<td className="px-4 py-2.5 text-right">
<div className="num text-foreground">{fmtPct(t.position_pct, 2)}</div>
<div className="mt-0.5 text-[11px] text-muted">
<span className="num">{fmtLots(t.lots)}</span>
<span className="ml-1 num">{fmtShares(t.shares)}</span>
</div>
</td>
<td className={`px-4 py-2.5 text-right num ${priceColorClass(t.pnl_amount ?? t.pnl_pct)}`}>
<div>{fmtSignedMoney(t.pnl_amount)}</div>
<div className="mt-0.5 text-[11px]">{fmtPct(t.pnl_pct)}</div>
</td>
<td className="px-4 py-2.5 text-right num text-secondary">
<div>{t.duration} </div>
{!!t.blocked_exit_days && <div className="mt-0.5 text-[11px] text-amber-400">阻塞 {t.blocked_exit_days} </div>}
</td>
<td className="px-4 py-2.5"><ExitReasonBadge reason={t.exit_reason} signalId={t.exit_signal_id} signalNames={signalNames} /></td>
</tr>
))}
</tbody>
</table>
{sortedTrades.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-2 text-xs text-muted">
<span>
显示 {tradeStart + 1}-{tradeEnd} / {sortedTrades.length}
</span>
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-1.5">
<span>每页</span>
<select
value={tradePageSize}
onChange={e => {
setTradePageSize(Number(e.target.value))
setTradePage(0)
}}
className="rounded-btn border border-border bg-surface px-2 py-1 text-xs text-secondary focus:outline-none focus:border-accent"
>
{TRADE_PAGE_SIZE_OPTIONS.map(size => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span></span>
</label>
<button
type="button"
onClick={() => setTradePage(p => Math.max(0, p - 1))}
disabled={safeTradePage <= 0}
className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
>
上一页
</button>
<span className="num text-secondary">
{safeTradePage + 1} / {tradePageCount}
</span>
<button
type="button"
onClick={() => setTradePage(p => Math.min(tradePageCount - 1, p + 1))}
disabled={safeTradePage >= tradePageCount - 1}
className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
>
下一页
</button>
</div>
</div>
)}
</div>
)}
{resultTab === 'picks' && (
<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.per_symbol_stats.map((r) => (
<tr key={r.symbol} className="border-t border-border hover:bg-elevated/50 transition-colors group">
<td className="px-4 py-2">
<div className="font-medium text-foreground group-hover:text-accent transition-colors">
{symbolNames[r.symbol] || r.symbol}
</div>
<div className="mt-0.5 font-mono text-[11px] text-muted">{r.symbol}</div>
</td>
<td className="px-4 py-2 text-right num">{r.n_trades}</td>
<td className={`px-4 py-2 text-right num ${priceColorClass(r.total_return)}`}>
{fmtPct(r.total_return)}
</td>
<td className="px-4 py-2 text-right num">{fmtPct(r.win_rate)}</td>
<td className="px-4 py-2 text-right num text-bull">{fmtPct(r.best)}</td>
<td className="px-4 py-2 text-right num text-bear">{fmtPct(r.worst)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
<div className="text-[11px] text-muted">
run_id: {result.run_id}
</div>
</motion.div>
)}
</section>
{settingsOpen && detail && (
<>
<motion.button
type="button"
aria-label="关闭高级策略设置"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
onClick={() => setSettingsOpen(false)}
className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[1px]"
/>
<motion.aside
initial={{ x: 32, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="fixed inset-y-0 right-0 z-[60] flex w-full max-w-3xl flex-col border-l border-border bg-base shadow-2xl"
>
<div className="border-b border-border px-4 py-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-foreground">高级策略设置</span>
<span className={`text-[9px] px-1 py-px rounded border ${BADGE_CLS_MAP[detail.source] ?? ''}`}>
{SRC_MAP[detail.source] ?? ''}
</span>
</div>
<div className="mt-1 truncate text-xs text-secondary">{detail.name}</div>
<div className="mt-0.5 text-[10px] leading-4 text-muted">{advancedSummary}</div>
</div>
<button
type="button"
onClick={() => setSettingsOpen(false)}
className="rounded-btn border border-border bg-surface p-1.5 text-muted transition-colors hover:border-accent/40 hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex gap-1 overflow-x-auto">
{visibleAdvancedTabs.map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setSettingsTab(tab.id)}
className={`shrink-0 rounded-btn border px-3 py-1.5 text-xs transition-colors ${settingsTab === tab.id
? 'border-accent/50 bg-accent/10 text-accent'
: 'border-border bg-surface text-secondary hover:border-accent/40 hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 rounded-btn border border-accent/25 bg-accent/5 px-3 py-2.5 text-[11px] leading-5 text-secondary">
<div className="font-medium text-foreground">触发 / 成交 / 仓位关系</div>
<div className="mt-1">触发器决定什么时候产生买卖信号;评分只在多个买点同时出现时排序。</div>
<div>成交口径可分别设置建仓/清仓:默认建仓次日开盘(避免未来函数)、清仓当日收盘(持仓中可盘中/收盘卖)。</div>
<div>退出优先级:止损/移动止损 &gt; 卖点信号 &gt; 到期平仓;到期只作兜底,不抢占卖点或风控。</div>
<div>最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。</div>
{matrixStrategy && <div className="text-accent">当前为 Matrix 策略,进出场信号由策略公式生成,不能用列信号覆盖。</div>}
</div>
{settingsTab === 'range' && (
<ConfigSection title="回测范围">
<div className="flex items-center gap-2">
<span className="text-[11px] text-muted">资产类型</span>
<div className="inline-flex h-8 rounded-btn border border-border overflow-hidden">
{(['stock', 'etf'] as const).map(t => (
<button
key={t}
type="button"
onClick={() => { setAssetType(t); setSelectedStrategy(null); setSymbols('') }}
className={`h-full px-3 text-xs font-medium transition-colors cursor-pointer
${assetType === t ? 'bg-accent/10 text-accent' : 'text-muted hover:text-foreground'}`}
>
{t === 'stock' ? '股票' : 'ETF'}
</button>
))}
</div>
<span className="text-[11px] text-muted/70">ETF 仅技术类策略, ETF enriched</span>
</div>
<StockPoolPicker value={symbols} onChange={setSymbols} assetType={assetType} />
<div className="text-[11px] leading-5 text-muted">默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。</div>
</ConfigSection>
)}
{settingsTab === 'params' && (
<ConfigSection title="策略参数" hint="自动限制 min/max">
{detail.params.length > 0 ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{detail.params.map(param => (
<StrategyParamInput
key={param.id}
param={param}
value={strategyParams[param.id]}
onChange={value => setStrategyParams(prev => ({ ...prev, [param.id]: value }))}
/>
))}
</div>
) : (
<div className="text-xs text-muted">当前策略没有可调参数。</div>
)}
</ConfigSection>
)}
{settingsTab === 'filter' && (
<ConfigSection title="基础过滤" hint="用于候选池">
<label className="flex items-center gap-2 text-xs text-secondary">
<input
type="checkbox"
checked={basicFilter.enabled !== false}
onChange={e => updateBasicFilter('enabled', e.target.checked)}
/>
启用基础过滤
</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{BASIC_FILTER_FIELDS.map(field => {
const scale = field.scale ?? 1
const raw = basicFilter[field.key]
return (
<label key={field.key} className="block">
<span className="mb-1 block text-[11px] text-secondary">{field.label}({field.unit})</span>
<NumberField
value={raw == null ? null : Number(raw) / scale}
min={0}
step={field.unit === '%' ? 0.1 : 0.01}
onChange={n => updateBasicFilter(field.key, n == null ? null : n * scale)}
className={INPUT_CLS}
/>
</label>
)
})}
</div>
<label className="flex items-center gap-2 text-xs text-secondary">
<input
type="checkbox"
checked={!!basicFilter.exclude_st}
onChange={e => updateBasicFilter('exclude_st', e.target.checked)}
/>
排除 ST / 退市
</label>
<div className="flex flex-wrap gap-1.5">
{BOARD_OPTIONS.map(board => {
const boards = Array.isArray(basicFilter.boards) ? basicFilter.boards : []
const checked = boards.includes(board)
return (
<button
key={board}
type="button"
onClick={() => updateBasicFilter('boards', checked ? boards.filter((b: string) => b !== board) : [...boards, board])}
className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${checked ? 'border-accent/50 bg-accent/10 text-accent' : 'border-border bg-base text-muted hover:border-accent/40'}`}
>
{board}
</button>
)
})}
</div>
</ConfigSection>
)}
{settingsTab === 'entry' && (
<ConfigSection
title="入场触发器"
hint="任一入场点满足即可进入候选"
actions={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={next => updateOverride('entry_signals', next)} />}
>
<SignalPicker
signals={entrySignals}
onChange={next => updateOverride('entry_signals', next)}
kind="entry"
/>
</ConfigSection>
)}
{settingsTab === 'exit' && (
<ConfigSection
title="出场触发器"
hint="任一出场点满足即触发出场"
actions={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={next => updateOverride('exit_signals', next)} />}
>
<SignalPicker
signals={exitSignals}
onChange={next => updateOverride('exit_signals', next)}
kind="exit"
/>
</ConfigSection>
)}
{settingsTab === 'scoring' && (
<ConfigSection title="评分方案" hint="选择因子、方向与权重,保存时自动归一化">
<ScoringEditor
key={detail.id}
value={scoring}
directions={scoringDirections}
fallbackLabels={FIELD_LABEL}
onChange={(nextScoring, nextDirections) => setOverrides(previous => ({
...previous,
scoring: nextScoring,
scoring_directions: nextDirections,
scoring_replace: true,
}))}
/>
<div className="border-t border-border/40 pt-3">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-[11px] font-medium text-secondary">评分过滤</span>
<span className="text-[10px] text-muted">留空 = 不过滤;命中范围后按评分从高到低买入</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">最小评分</span>
<NumberField
value={overrides.score_min == null ? null : Number(overrides.score_min)}
min={0} max={100} step={1} placeholder="不限"
onChange={n => updateOverride('score_min', n)}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">最大评分</span>
<NumberField
value={overrides.score_max == null ? null : Number(overrides.score_max)}
min={0} max={100} step={1} placeholder="不限"
onChange={n => updateOverride('score_max', n)}
className={INPUT_CLS}
/>
</label>
</div>
<div className="mt-2 text-[10px] leading-4 text-muted">例如最小值 71 表示只把评分 71 的股票放入下一交易日买入预选池。</div>
</div>
</ConfigSection>
)}
{settingsTab === 'risk' && (
<ConfigSection title="风控">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">止损(%)</span>
<NumberField
value={numOrNull(stopLossPct)}
min={0} max={99} step={0.5}
onChange={n => updateOverride('stop_loss', n == null ? null : -Math.abs(n) / 100)}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">止盈(%)</span>
<NumberField
value={numOrNull(takeProfitPct)}
min={1} max={500} step={0.5}
onChange={n => updateOverride('take_profit', n == null ? null : Math.abs(n) / 100)}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">移动止损(%)</span>
<NumberField
value={numOrNull(trailingStopPct)}
min={0.5} max={50} step={0.5}
onChange={n => updateOverride('trailing_stop', n == null ? null : -Math.abs(n) / 100)}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">回撤止盈启动(%)</span>
<NumberField
value={numOrNull(trailingTakeProfitActivatePct)}
min={1} max={200} step={0.5}
onChange={n => {
const next = n == null ? null : Math.abs(n) / 100
updateOverride('trailing_take_profit_activate', next)
// 联动: 回撤不能超过启动值
const drawdown = numOrNull(trailingTakeProfitDrawdownPct)
if (next != null && drawdown != null && drawdown / 100 > next) {
updateOverride('trailing_take_profit_drawdown', next)
}
}}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">回撤止盈回撤()</span>
<NumberField
value={numOrNull(trailingTakeProfitDrawdownPct)}
min={0.5} max={50} step={0.5}
onChange={n => updateOverride('trailing_take_profit_drawdown', n == null ? null : Math.abs(n) / 100)}
className={INPUT_CLS}
/>
</label>
<label className="block">
<span className="mb-1 block text-[11px] text-secondary">最长持仓()</span>
<NumberField
value={numOrNull(maxHoldDaysValue)}
min={1} step={1}
onChange={n => updateOverride('max_hold_days', n == null ? null : Math.round(n))}
className={INPUT_CLS}
/>
</label>
</div>
</ConfigSection>
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<button
type="button"
onClick={() => resetConfigFromDetail(detail)}
className="rounded-btn border border-border bg-surface px-3 py-1.5 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent"
>
恢复默认
</button>
{/* 应用到策略: 把当前配置持久化为策略定义(仅用户自有策略可改) */}
{(detail.source === 'custom' || detail.source === 'ai' || detail.source === 'composite') && (
<button
type="button"
onClick={handleApplyToStrategy}
disabled={applying}
className="ml-auto rounded-btn border border-emerald-500/30 bg-emerald-500/8 px-3 py-1.5 text-xs font-medium text-emerald-500 transition-colors hover:bg-emerald-500/15 disabled:opacity-50"
>
{applying ? '应用中…' : '应用到策略'}
</button>
)}
<button
type="button"
onClick={() => setSettingsOpen(false)}
className="rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90"
>
完成
</button>
</div>
</motion.aside>
</>
)}
<TradeKlineModal trade={selectedTrade} onClose={() => setSelectedTrade(null)} />
</div>
)
}