mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 19:04:15 +08:00
2734 lines
135 KiB
TypeScript
2734 lines
135 KiB
TypeScript
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
|
import { useQuery } 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 } from 'lucide-react'
|
|
import {
|
|
api,
|
|
type StrategyBacktestResult,
|
|
type StrategyBacktestTrade,
|
|
type StrategyDetail,
|
|
type StrategyParamDef,
|
|
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'
|
|
|
|
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 ?? {}) }
|
|
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 },
|
|
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,
|
|
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'
|
|
const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10)
|
|
const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10)
|
|
const price = isBuy ? trade.entry_price : trade.exit_price
|
|
const amount = isBuy ? trade.entry_value : trade.exit_value
|
|
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}</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>
|
|
)
|
|
}
|
|
|
|
|
|
const scoringToPct = (values: Record<string, number>) => {
|
|
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
|
|
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
|
|
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record<string, number>
|
|
}
|
|
|
|
const normalizePctWeights = (values: Record<string, number>) => {
|
|
const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
|
|
if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record<string, number>
|
|
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record<string, number>
|
|
}
|
|
|
|
function ScoringWeightRow({ name, weight, pct, editing, onChange }: {
|
|
name: string
|
|
weight: number
|
|
pct: number
|
|
editing: boolean
|
|
onChange: (value: number) => void
|
|
}) {
|
|
const label = FIELD_LABEL[name] ?? name
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-20 shrink-0 truncate text-right text-[11px] text-secondary" title={name}>{label}</span>
|
|
{editing ? (
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={weight}
|
|
onChange={e => onChange(Number(e.target.value))}
|
|
className="h-1 flex-1 cursor-pointer accent-amber-400"
|
|
/>
|
|
) : (
|
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-elevated">
|
|
<div className="h-full rounded-full bg-amber-400/70 transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
</div>
|
|
)}
|
|
<span className="w-10 text-right font-mono text-[10px] text-muted">{editing ? weight : `${pct}%`}</span>
|
|
</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 groupId = entry.group_id ?? 'ungrouped'
|
|
counts[groupId] = (counts[groupId] ?? 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))
|
|
// 按分组导入自选: 合并去重, 顺带回填股票名
|
|
const importFromWatchlist = (groupId: string | null) => {
|
|
const entries = groupId === 'all'
|
|
? watchlistEntries
|
|
: watchlistEntries.filter(entry => (entry.group_id ?? null) === 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 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[]>([])
|
|
const [regimeMinScore, setRegimeMinScore] = useState<number | ''>('')
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
// 分钟K成交价细化: 不改变信号日或成交日, 需 Pro+ 分钟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 [editingScoring, setEditingScoring] = useState(false)
|
|
const [scoringDraft, setScoringDraft] = useState<Record<string, number>>({})
|
|
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),
|
|
queryFn: () => api.screenerStrategies(assetType),
|
|
})
|
|
|
|
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 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: highGranularity,
|
|
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: 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
|
|
|
|
useEffect(() => {
|
|
if (highGranularity && minuteExitTriggerSupported) return
|
|
if (exitFill === 'signal_next_minute') setExitFill('close_t')
|
|
}, [exitFill, highGranularity, minuteExitTriggerSupported])
|
|
|
|
const scoring = useMemo(() => (overrides.scoring ?? {}) as Record<string, number>, [overrides.scoring])
|
|
const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
|
|
const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
|
|
const stopLossPct = overrides.stop_loss == null ? '' : String(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 (!editingScoring) setScoringDraft(scoringToPct(scoring))
|
|
}, [scoring, editingScoring])
|
|
|
|
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 startScoringEdit = () => {
|
|
setScoringDraft(scoringToPct(scoring))
|
|
setEditingScoring(true)
|
|
}
|
|
const cancelScoringEdit = () => {
|
|
setScoringDraft(scoringToPct(scoring))
|
|
setEditingScoring(false)
|
|
}
|
|
const saveScoringDraft = () => {
|
|
updateOverride('scoring', normalizePctWeights(scoringDraft))
|
|
setEditingScoring(false)
|
|
}
|
|
const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== ''
|
|
? `评分 ${scoreMinValue}~${scoreMaxValue}`
|
|
: scoreMinValue !== ''
|
|
? `评分 ≥${scoreMinValue}`
|
|
: scoreMaxValue !== ''
|
|
? `评分 ≤${scoreMaxValue}`
|
|
: '评分不过滤'
|
|
const advancedSummary = detail
|
|
? [
|
|
detail.params.length > 0 ? `参数 ${detail.params.length}` : '无策略参数',
|
|
basicFilter.enabled !== false ? '过滤开' : '过滤关',
|
|
`买点 ${entrySignals.length}`,
|
|
`卖点 ${exitSignals.length}`,
|
|
scoreFilterSummary,
|
|
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
|
|
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 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成交 */}
|
|
<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成交价:需 Pro+ 权限 (分钟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">Pro+</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* 分钟K开启时的提示条 */}
|
|
{highGranularity && hasMinuteBatch && (
|
|
<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.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>
|
|
<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 && (
|
|
<option value="signal_next_minute">信号触发卖出 BETA</option>
|
|
)}
|
|
</select>
|
|
</div>
|
|
{(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>
|
|
{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>
|
|
)
|
|
)}
|
|
</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>
|
|
<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>
|
|
)}
|
|
</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>退出优先级:止损/移动止损 > 卖点信号 > 到期平仓;到期只作兜底,不抢占卖点或风控。</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="临时拖动滑块,保存时统一归权">
|
|
{Object.entries(scoring).length > 0 ? (() => {
|
|
const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring)
|
|
const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0)
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
{Object.keys(scoring).map(key => (
|
|
<ScoringWeightRow
|
|
key={key}
|
|
name={key}
|
|
weight={visibleWeights[key] ?? 0}
|
|
pct={visibleWeights[key] ?? 0}
|
|
editing={editingScoring}
|
|
onChange={value => setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border/40 pt-2">
|
|
<div className="text-[10px] text-muted">
|
|
总和 <span className={`font-mono text-xs font-medium ${editingScoring && total !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}>{editingScoring ? total : 100}</span>
|
|
<span className="ml-1 text-muted/70">保存时自动归一化计算</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{editingScoring && (
|
|
<button
|
|
type="button"
|
|
onClick={cancelScoringEdit}
|
|
className="rounded-btn border border-border bg-base px-2.5 py-1 text-[11px] text-secondary transition-colors hover:border-accent/40 hover:text-foreground"
|
|
>
|
|
取消
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={editingScoring ? saveScoringDraft : startScoringEdit}
|
|
className="rounded-btn border border-amber-400/40 bg-amber-400/10 px-2.5 py-1 text-[11px] text-amber-400 transition-colors hover:bg-amber-400/15"
|
|
>
|
|
{editingScoring ? '保存归权' : '调整权重'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})() : (
|
|
<div className="text-xs text-muted">当前策略没有评分权重。</div>
|
|
)}
|
|
<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>
|
|
)
|
|
}
|