2189 lines
107 KiB
TypeScript
2189 lines
107 KiB
TypeScript
import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { motion } from 'framer-motion'
|
|
import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
|
|
import {
|
|
api,
|
|
type StrategyBacktestResult,
|
|
type StrategyBacktestTrade,
|
|
type StrategyDetail,
|
|
type StrategyParamDef,
|
|
} from '@/lib/api'
|
|
import { QK } from '@/lib/queryKeys'
|
|
import { tierRank } from '@/lib/capability-labels'
|
|
import { storage } from '@/lib/storage'
|
|
import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
|
|
import { boardTag } from '@/lib/board'
|
|
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
|
import { SignalPicker } from '@/components/screener/SignalPicker'
|
|
import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask'
|
|
import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
|
|
import { EmptyState } from '@/components/EmptyState'
|
|
import { DatePicker } from '@/components/DatePicker'
|
|
import { StrategyNavChart } from './charts/StrategyNavChart'
|
|
import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
|
|
import { TradeKlineModal } from './components/TradeKlineModal'
|
|
import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions'
|
|
|
|
const formatDate = (date: Date) => date.toISOString().slice(0, 10)
|
|
const monthsAgo = (months: number) => {
|
|
const date = new Date()
|
|
date.setMonth(date.getMonth() - months)
|
|
return formatDate(date)
|
|
}
|
|
const TODAY = formatDate(new Date())
|
|
const THREE_MONTHS_AGO = monthsAgo(3)
|
|
|
|
type QuickRangeUnit = 'month' | 'year' | 'all'
|
|
type QuickRangeConfig = { id: string; enabled: boolean; unit: QuickRangeUnit; value: number }
|
|
|
|
const QUICK_RANGE_LIMITS = {
|
|
month: { min: 1, max: 120 },
|
|
year: { min: 1, max: 10 },
|
|
} as const
|
|
const DEFAULT_QUICK_RANGES: QuickRangeConfig[] = [
|
|
{ id: 'range-1', enabled: true, unit: 'month', value: 3 },
|
|
{ id: 'range-2', enabled: true, unit: 'month', value: 6 },
|
|
{ id: 'range-3', enabled: true, unit: 'year', value: 1 },
|
|
{ id: 'range-4', enabled: true, unit: 'all', value: 0 },
|
|
]
|
|
const quickRangeValue = (unit: QuickRangeUnit, value: unknown, fallback: number) => {
|
|
if (unit === 'all') return 0
|
|
const limits = QUICK_RANGE_LIMITS[unit]
|
|
const num = Number(value)
|
|
const safe = Number.isFinite(num) ? Math.round(num) : fallback
|
|
return clamp(safe, limits.min, limits.max)
|
|
}
|
|
const normalizeQuickRange = (raw: unknown, fallback: QuickRangeConfig): QuickRangeConfig => {
|
|
const obj = raw && typeof raw === 'object' ? raw as Partial<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`
|
|
|
|
const SRC_MAP: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
|
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',
|
|
}
|
|
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: '布林下轨',
|
|
})
|
|
const BOARD_OPTIONS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
|
|
const BASIC_FILTER_FIELDS = [
|
|
{ key: 'price_min', label: '最低价', unit: '元' },
|
|
{ key: 'price_max', label: '最高价', unit: '元' },
|
|
{ key: 'amount_min', label: '最低成交额', unit: '亿', scale: 1e8 },
|
|
{ key: 'market_cap_min', label: '最低总市值', unit: '亿', scale: 1e8 },
|
|
{ key: 'turnover_min', label: '最低换手率', unit: '%' },
|
|
{ key: 'turnover_max', label: '最高换手率', unit: '%' },
|
|
]
|
|
type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range'
|
|
type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin'
|
|
const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [
|
|
{ id: 'all', label: '全部' },
|
|
{ id: 'custom', label: '自定义' },
|
|
{ id: 'ai', label: 'AI' },
|
|
{ id: 'builtin', label: '内置' },
|
|
]
|
|
const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [
|
|
{ id: 'params', label: '策略参数' },
|
|
{ id: 'filter', label: '基础过滤' },
|
|
{ id: 'entry', label: '买入触发器' },
|
|
{ id: 'exit', label: '卖出触发器' },
|
|
{ id: 'scoring', label: '评分权重' },
|
|
{ id: 'risk', label: '风控' },
|
|
{ id: 'range', label: '回测范围' },
|
|
]
|
|
const toSignalId = (sig: string) => (sig.startsWith('signal_') || sig.startsWith('csg_')) ? sig : `signal_${sig}`
|
|
const numOrNull = (v: string) => v === '' || Number.isNaN(Number(v)) ? null : Number(v)
|
|
const clamp = (v: number, min?: number, max?: number) => {
|
|
let next = v
|
|
if (min != null) next = Math.max(next, min)
|
|
if (max != null) next = Math.min(next, max)
|
|
return next
|
|
}
|
|
const strategyDefaultParams = (detail: StrategyDetail) => {
|
|
const values: Record<string, any> = { ...detail.params_defaults }
|
|
detail.params.forEach(p => {
|
|
if (!(p.id in values)) values[p.id] = p.default
|
|
})
|
|
return values
|
|
}
|
|
const buildDefaultOverrides = (detail: StrategyDetail) => ({
|
|
basic_filter: { ...detail.basic_filter },
|
|
entry_signals: detail.entry_signals.map(toSignalId),
|
|
exit_signals: detail.exit_signals.map(toSignalId),
|
|
scoring: { ...detail.scoring },
|
|
stop_loss: detail.stop_loss,
|
|
trailing_stop: detail.trailing_stop,
|
|
trailing_take_profit_activate: detail.trailing_take_profit_activate,
|
|
trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
|
|
score_min: null,
|
|
score_max: null,
|
|
max_hold_days: detail.max_hold_days,
|
|
})
|
|
|
|
const fmtMoney = (v: number | null | undefined) => {
|
|
if (v == null || Number.isNaN(v)) return '—'
|
|
return v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
|
}
|
|
|
|
const fmtSignedMoney = (v: number | null | undefined) => {
|
|
if (v == null || Number.isNaN(v)) return '—'
|
|
const sign = v > 0 ? '+' : ''
|
|
return `${sign}${fmtMoney(v)}`
|
|
}
|
|
|
|
const fmtShares = (v: number | null | undefined) => {
|
|
if (v == null || Number.isNaN(v)) return '—'
|
|
return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
|
}
|
|
|
|
const fmtLots = (v: number | null | undefined) => {
|
|
if (v == null || Number.isNaN(v)) return '—'
|
|
return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
|
}
|
|
|
|
const statValueColor = (v: number | null | undefined) => {
|
|
if (v == null || Number.isNaN(v) || v === 0) return '#f8fafc'
|
|
return v > 0 ? '#f87171' : '#34d399'
|
|
}
|
|
|
|
function ExitReasonBadge({ reason }: { reason: string }) {
|
|
const config: Record<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' },
|
|
trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
|
|
trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
|
max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
|
|
pending_exit: { label: '待卖', cls: 'bg-orange-400/10 text-orange-400 border-orange-400/30' },
|
|
end: { label: '期末', cls: 'bg-secondary/10 text-secondary border-border' },
|
|
}
|
|
const c = config[reason] ?? { label: reason, cls: 'bg-elevated text-muted border-border' }
|
|
return (
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded border ${c.cls}`}>{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 }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void }) {
|
|
const isBuy = side === 'buy'
|
|
const tag = boardTag(trade.symbol)
|
|
const price = isBuy ? trade.entry_price : trade.exit_price
|
|
const amount = isBuy ? trade.entry_value : trade.exit_value
|
|
const pnlColor = priceColorClass(trade.pnl_amount ?? trade.pnl_pct)
|
|
const footerColor = isBuy ? 'text-secondary' : pnlColor
|
|
const footerText = `仓位 ${fmtPositionPct(trade.position_pct, 2)}`
|
|
const scoreText = fmtScore(trade.entry_score)
|
|
const buyStrategy = strategyName || '策略'
|
|
|
|
return (
|
|
<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} />
|
|
</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 }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell' }) {
|
|
const isBuy = side === 'buy'
|
|
const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10)
|
|
const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10)
|
|
const price = isBuy ? trade.entry_price : trade.exit_price
|
|
const amount = isBuy ? trade.entry_value : trade.exit_value
|
|
|
|
return (
|
|
<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>
|
|
{signalDate && signalDate !== date && (
|
|
<div className="mt-0.5 text-[10px] text-muted">信号 {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}秒`
|
|
}
|
|
|
|
function SharpeLabel() {
|
|
const [open, setOpen] = useState(false)
|
|
const [alignRight, setAlignRight] = useState(false)
|
|
const ref = useRef<HTMLSpanElement>(null)
|
|
useEffect(() => {
|
|
if (!open) return
|
|
const onClick = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', onClick)
|
|
return () => document.removeEventListener('mousedown', onClick)
|
|
}, [open])
|
|
const toggle = () => {
|
|
if (!open && ref.current) {
|
|
const rect = ref.current.getBoundingClientRect()
|
|
setAlignRight(rect.left + 240 > window.innerWidth)
|
|
}
|
|
setOpen(o => !o)
|
|
}
|
|
return (
|
|
<span className="relative inline-flex items-center gap-1" ref={ref}>
|
|
夏普
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
className="inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border border-border bg-base text-[10px] text-muted transition-colors hover:border-accent/50 hover:text-accent"
|
|
>
|
|
?
|
|
</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">夏普比率 (Sharpe Ratio)</span>
|
|
<span className="mt-1 block">衡量<b className="text-foreground">单位波动风险</b>换来的超额收益。</span>
|
|
<span className="mt-0.5 block">数值越高,收益相对波动越优秀;</span>
|
|
<span className="mt-0.5 block text-warning">短周期或交易次数少时容易偏高,仅供参考。</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 ?? '#f8fafc' }}
|
|
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>
|
|
<input
|
|
type="number"
|
|
value={value ?? ''}
|
|
min={param.min}
|
|
max={param.max}
|
|
step={param.step ?? (param.type === 'int' ? 1 : 0.01)}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
if (n == null) return onChange('')
|
|
const next = clamp(n, param.min, param.max)
|
|
onChange(param.type === 'int' ? Math.round(next) : next)
|
|
}}
|
|
className={INPUT_CLS}
|
|
/>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function StockPoolPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
|
|
const symbols = useMemo(() => value.split(',').map(s => s.trim()).filter(Boolean), [value])
|
|
const [query, setQuery] = useState('')
|
|
const [open, setOpen] = useState(false)
|
|
const [symbolNames, setSymbolNames] = useState<Record<string, string>>({})
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const search = useQuery({
|
|
queryKey: QK.instrumentSearch(query),
|
|
queryFn: () => api.instrumentSearch(query),
|
|
enabled: query.trim().length > 0,
|
|
staleTime: 30_000,
|
|
})
|
|
const results = search.data?.results ?? []
|
|
|
|
useEffect(() => {
|
|
if (results.length === 0) return
|
|
setSymbolNames(prev => {
|
|
const next = { ...prev }
|
|
results.forEach(r => {
|
|
if (r.name) next[r.symbol] = r.name
|
|
})
|
|
return next
|
|
})
|
|
}, [results])
|
|
|
|
useEffect(() => {
|
|
const onClick = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', onClick)
|
|
return () => document.removeEventListener('mousedown', onClick)
|
|
}, [])
|
|
|
|
const setSymbols = (next: string[]) => onChange(Array.from(new Set(next)).join(','))
|
|
const addSymbol = (symbol: string, name?: string | null) => {
|
|
if (name) setSymbolNames(prev => ({ ...prev, [symbol]: name }))
|
|
setSymbols([...symbols, symbol])
|
|
setQuery('')
|
|
setOpen(false)
|
|
}
|
|
const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
|
|
|
|
return (
|
|
<div className="space-y-2" ref={ref}>
|
|
<div className="relative">
|
|
<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>
|
|
<Plus className={`h-3.5 w-3.5 ${added ? 'opacity-30' : 'text-accent'}`} />
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{symbols.length === 0 ? (
|
|
<span className="text-[11px] font-medium text-amber-400">留空 = 全市场,由基础过滤和策略条件筛选。</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 [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 [start, setStart] = useState(saved?.start ?? THREE_MONTHS_AGO)
|
|
const [end, setEnd] = useState(saved?.end ?? TODAY)
|
|
// 成交口径: 建仓/清仓可独立配置。向后兼容老 matching (派生为 entry=exit=matching)。
|
|
const [matching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1')
|
|
const [entryFill, setEntryFill] = useState<'close_t' | 'open_t+1'>(saved?.entryFill ?? saved?.matching ?? 'open_t+1')
|
|
const [exitFill, setExitFill] = useState<'close_t' | 'open_t+1'>(saved?.exitFill ?? saved?.matching ?? 'close_t')
|
|
const [fees, setFees] = useState(saved?.fees ?? '2')
|
|
const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10')
|
|
const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100')
|
|
const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000')
|
|
const [positionSizing, setPositionSizing] = useState<'equal' | 'score_weight'>(saved?.positionSizing ?? 'equal')
|
|
const [simMode, setSimMode] = useState<'position' | 'full'>(saved?.mode ?? 'position')
|
|
const [holdingDays, setHoldingDays] = useState(saved?.holdingDays ?? '5')
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
// 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能
|
|
const [highGranularity, setHighGranularity] = useState(false)
|
|
const { data: caps } = useCapabilities()
|
|
const isFreeTier = tierRank(caps?.label ?? '') < 1
|
|
const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
|
|
const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
|
|
const [settingsTab, setSettingsTab] = useState<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,
|
|
queryFn: api.screenerStrategies,
|
|
})
|
|
|
|
const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data])
|
|
const filteredStrategyList = useMemo(() => (
|
|
strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
|
|
), [strategyGroup, strategyList])
|
|
|
|
// 校验 localStorage 里保存的上次选中策略是否仍存在(本地开发残留的自定义策略
|
|
// 拉新代码后会失效,导致 strategyGet 一直 404/加载中)。列表就绪后若失效,
|
|
// 连带清除其专属的 params/overrides/result(这些是该策略的运行配置/产物,
|
|
// 策略失效后留着会造成"孤儿"状态:界面显示旧回测结果却无对应策略)。
|
|
useEffect(() => {
|
|
if (strategies.isLoading || strategyList.length === 0) return
|
|
if (selectedStrategy && !strategyList.some(st => st.id === selectedStrategy)) {
|
|
setSelectedStrategy(null)
|
|
setStrategyParams({})
|
|
setOverrides({})
|
|
setResult(null)
|
|
}
|
|
}, [strategies.isLoading, strategyList, selectedStrategy])
|
|
|
|
const strategyDetail = useQuery({
|
|
queryKey: ['strategy-detail', selectedStrategy],
|
|
queryFn: () => api.strategyGet(selectedStrategy!),
|
|
enabled: !!selectedStrategy,
|
|
})
|
|
|
|
const backtestTask = useBacktestTask()
|
|
const isPending = backtestTask?.isPending ?? false
|
|
|
|
const dataStatus = useDataStatus()
|
|
const earliestDate = dataStatus.data?.daily?.earliest_date ?? null
|
|
|
|
const resetConfigFromDetail = (detail: StrategyDetail) => {
|
|
setStrategyParams(strategyDefaultParams(detail))
|
|
setOverrides(buildDefaultOverrides(detail))
|
|
}
|
|
|
|
// 刷新页面后: 从 localStorage 恢复未完成的回测任务
|
|
useEffect(() => {
|
|
tryReconnect()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const detail = strategyDetail.data
|
|
if (!detail || loadedStrategyRef.current === detail.id) return
|
|
loadedStrategyRef.current = detail.id
|
|
if (saved?.selectedStrategy === detail.id && (saved.params || saved.overrides)) {
|
|
setStrategyParams(saved.params ?? strategyDefaultParams(detail))
|
|
setOverrides(saved.overrides ?? buildDefaultOverrides(detail))
|
|
return
|
|
}
|
|
resetConfigFromDetail(detail)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [strategyDetail.data])
|
|
|
|
// 当全局回测任务完成时, 把结果写入组件 (切页回来也能恢复)
|
|
useEffect(() => {
|
|
if (backtestTask && !backtestTask.isPending && backtestTask.result) {
|
|
setResult(backtestTask.result)
|
|
setResultTab('daily')
|
|
setDailyPage(0)
|
|
setTradePage(0)
|
|
storage.strategyBacktestLast.set({
|
|
selectedStrategy,
|
|
symbols,
|
|
start,
|
|
end,
|
|
matching,
|
|
entryFill,
|
|
exitFill,
|
|
fees,
|
|
maxPositions,
|
|
maxExposure,
|
|
initialCapital,
|
|
positionSizing,
|
|
mode: simMode,
|
|
holdingDays,
|
|
params: strategyParams,
|
|
overrides,
|
|
result: backtestTask.result,
|
|
})
|
|
}
|
|
}, [backtestTask])
|
|
|
|
const handleRun = () => {
|
|
if (!selectedStrategy) return
|
|
startBacktest({
|
|
strategy_id: selectedStrategy,
|
|
symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
|
|
start: start || null,
|
|
end: end || undefined,
|
|
matching,
|
|
entry_fill: entryFill,
|
|
exit_fill: exitFill,
|
|
fees_pct: Number(fees) / 10000,
|
|
max_positions: Number(maxPositions),
|
|
max_exposure_pct: Number(maxExposure) / 100,
|
|
initial_capital: Number(initialCapital),
|
|
position_sizing: positionSizing,
|
|
params: strategyParams,
|
|
overrides,
|
|
mode: simMode,
|
|
holding_days: Number(holdingDays) || 5,
|
|
})
|
|
}
|
|
|
|
// 提取统计
|
|
const s = result?.stats
|
|
const pick = (...keys: string[]) => {
|
|
for (const k of keys) {
|
|
if (s && k in s && s[k] != null) return s[k]
|
|
}
|
|
return null
|
|
}
|
|
|
|
const benchmarkReturn = useMemo(() => {
|
|
const values = (result?.benchmark_curve ?? [])
|
|
.map(r => Number(r.close ?? r.value))
|
|
.filter(v => Number.isFinite(v) && v > 0)
|
|
if (values.length < 2) return null
|
|
return values[values.length - 1] / values[0] - 1
|
|
}, [result?.benchmark_curve])
|
|
|
|
const strategyReturn = pick('total_return') as number | null
|
|
const excessReturn = strategyReturn != null && benchmarkReturn != null
|
|
? strategyReturn - benchmarkReturn
|
|
: null
|
|
|
|
const applyRange = (months: number) => {
|
|
setStart(monthsAgo(months))
|
|
setEnd(formatDate(new Date()))
|
|
}
|
|
|
|
const applyAllRange = () => {
|
|
setStart(earliestDate ?? '')
|
|
setEnd(formatDate(new Date()))
|
|
}
|
|
|
|
// 进入页面/还在加载时就点了"全部": earliestDate 就绪后回填, 让 DatePicker 显示真实起始日
|
|
useEffect(() => {
|
|
if (earliestDate && start === '' && end === TODAY) {
|
|
setStart(earliestDate)
|
|
}
|
|
}, [earliestDate, start, end])
|
|
|
|
const applyQuickRange = (range: QuickRangeConfig) => {
|
|
if (range.unit === 'all') {
|
|
applyAllRange()
|
|
return
|
|
}
|
|
applyRange(quickRangeMonths(range))
|
|
}
|
|
|
|
const saveQuickRanges = (next: QuickRangeConfig[]) => {
|
|
const normalized = normalizeQuickRanges(next)
|
|
storage.strategyBacktestQuickRanges.set(normalized)
|
|
return normalized
|
|
}
|
|
|
|
const updateQuickRange = (id: string, patch: Partial<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 basicFilter = (overrides.basic_filter ?? {}) as Record<string, any>
|
|
const entrySignals = (overrides.entry_signals ?? []) as string[]
|
|
const exitSignals = (overrides.exit_signals ?? []) as string[]
|
|
|
|
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(Math.abs(Number(overrides.stop_loss)) * 100)
|
|
const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
|
|
const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
|
|
const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
|
|
const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days)
|
|
const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0
|
|
|
|
useEffect(() => {
|
|
if (!editingScoring) setScoringDraft(scoringToPct(scoring))
|
|
}, [scoring, editingScoring])
|
|
|
|
const updateOverride = (key: string, value: any) => {
|
|
setOverrides(prev => ({ ...prev, [key]: value }))
|
|
}
|
|
const updateBasicFilter = (key: string, value: any) => {
|
|
updateOverride('basic_filter', { ...basicFilter, [key]: value })
|
|
}
|
|
const startScoringEdit = () => {
|
|
setScoringDraft(scoringToPct(scoring))
|
|
setEditingScoring(true)
|
|
}
|
|
const cancelScoringEdit = () => {
|
|
setScoringDraft(scoringToPct(scoring))
|
|
setEditingScoring(false)
|
|
}
|
|
const saveScoringDraft = () => {
|
|
updateOverride('scoring', normalizePctWeights(scoringDraft))
|
|
setEditingScoring(false)
|
|
}
|
|
const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== ''
|
|
? `评分 ${scoreMinValue}~${scoreMaxValue}`
|
|
: scoreMinValue !== ''
|
|
? `评分 ≥${scoreMinValue}`
|
|
: scoreMaxValue !== ''
|
|
? `评分 ≤${scoreMaxValue}`
|
|
: '评分不过滤'
|
|
const advancedSummary = detail
|
|
? [
|
|
detail.params.length > 0 ? `参数 ${detail.params.length}` : '无策略参数',
|
|
basicFilter.enabled !== false ? '过滤开' : '过滤关',
|
|
`买点 ${entrySignals.length}`,
|
|
`卖点 ${exitSignals.length}`,
|
|
scoreFilterSummary,
|
|
stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
|
|
trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
|
|
trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设',
|
|
maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓',
|
|
].join(' · ')
|
|
: '选择策略后可调整参数 / 过滤 / 买卖触发器 / 评分 / 风控'
|
|
const selectedStrategyName = detail?.name ?? strategyList.find(st => st.id === selectedStrategy)?.name ?? '未选择策略'
|
|
const selectedStrategySource = detail?.source ?? strategyList.find(st => st.id === selectedStrategy)?.source
|
|
const stockPoolCount = symbols.split(',').map(s => s.trim()).filter(Boolean).length
|
|
const stockPoolSummary = stockPoolCount > 0 ? `股票池 已限定 ${stockPoolCount} 只` : '股票池 全市场'
|
|
const resultStartDate = result?.config?.start ?? result?.equity_curve?.[0]?.date ?? start
|
|
const resultEndDate = result?.config?.end ?? result?.equity_curve?.[result.equity_curve.length - 1]?.date ?? end
|
|
const resultTradeDays = result?.equity_curve?.length ?? 0
|
|
const executionStats = (result?.stats?.execution ?? {}) as Record<string, number>
|
|
const executionSummary = [
|
|
['buy_no_slot', '满仓未买'],
|
|
['buy_exposure', '仓位上限'],
|
|
['buy_score_filter', '评分过滤'],
|
|
['buy_limit_up', '涨停未买'],
|
|
['buy_suspended', '停牌未买'],
|
|
['sell_limit_down', '跌停阻塞'],
|
|
['sell_suspended', '停牌阻塞'],
|
|
['pending_exit', '待卖阻塞'],
|
|
]
|
|
.map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) }))
|
|
.filter(item => item.value > 0)
|
|
|
|
return (
|
|
<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={() => {
|
|
if (isFreeTier) return
|
|
// 功能开发中,暂不实际启用
|
|
setHighGranularity(v => !v)
|
|
}}
|
|
disabled={isFreeTier}
|
|
title={isFreeTier
|
|
? '高颗粒回测(分钟K精确回测):需 Starter+ 档位'
|
|
: '高颗粒回测(分钟K精确回测):切换后结合每日分钟K更精确回测。⚠️ 开发中,且会显著影响性能、回测很慢。'
|
|
}
|
|
className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
|
|
isFreeTier ? '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'}`}>分钟K</span>
|
|
{isFreeTier && (
|
|
<span className="text-[8px] text-accent/70 font-medium bg-accent/10 px-1 py-px rounded">Starter+</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* 高颗粒开启时的警告条 */}
|
|
{highGranularity && !isFreeTier && (
|
|
<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">高颗粒回测(开发中)</span>
|
|
:将结合每日分钟K进行更精确的回测。
|
|
<span className="text-amber-400/70"> ⚠️ 此功能尚未完成,且开启后会显著拖慢回测速度、占用大量资源。</span>
|
|
</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="text-xs font-medium text-foreground">回测区间</div>
|
|
<span className="shrink-0 rounded-full border border-accent/25 bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">
|
|
{rangeTitle}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="text-[11px] text-secondary block mb-1">开始</label>
|
|
<DatePicker
|
|
value={start}
|
|
onChange={setStart}
|
|
max={end || undefined}
|
|
placeholder="全部历史"
|
|
className="w-full"
|
|
buttonClassName="w-full justify-start"
|
|
align="left"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-[11px] text-secondary block mb-1">结束</label>
|
|
<DatePicker
|
|
value={end}
|
|
onChange={setEnd}
|
|
min={start || undefined}
|
|
className="w-full"
|
|
buttonClassName="w-full justify-start"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex 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>
|
|
<label className="text-xs font-medium text-secondary block mb-1.5">建仓口径</label>
|
|
<select value={entryFill} onChange={e => setEntryFill(e.target.value as any)} className={INPUT_CLS}>
|
|
<option value="open_t+1">次日开盘成交(推荐)</option>
|
|
<option value="close_t">信号日收盘成交</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-secondary block mb-1.5">清仓口径</label>
|
|
<select value={exitFill} onChange={e => setExitFill(e.target.value as any)} className={INPUT_CLS}>
|
|
<option value="close_t">到期/信号日收盘成交(推荐)</option>
|
|
<option value="open_t+1">次日开盘成交</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="mt-1 text-[10px] leading-4 text-muted">建仓默认次日开盘(避免未来函数),清仓默认当日收盘(持仓中可盘中/收盘卖);买卖点由策略触发器决定,这里只决定成交价。</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>
|
|
<label className="text-xs font-medium text-secondary block mb-1.5">佣金(万分之)</label>
|
|
<input type="number" value={fees} onChange={e => setFees(e.target.value)} className={INPUT_CLS} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{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>
|
|
)}
|
|
|
|
{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}
|
|
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>
|
|
|
|
{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">
|
|
{backtestTask.error}
|
|
</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="text-xs font-medium text-accent">
|
|
{backtestTask?.progress
|
|
? `回测中 · 第 ${backtestTask.progress.day}/${backtestTask.progress.total} 天 (${backtestTask.progress.date})`
|
|
: '正在重新计算回测…'}
|
|
</div>
|
|
<div className="mt-0.5 text-[11px] text-secondary">
|
|
{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="平均收益" value={fmtPct(result.stats.avg_return)} color={statValueColor(result.stats.avg_return)} />
|
|
<Stat label="中位数" value={fmtPct(result.stats.median_return)} color={statValueColor(result.stats.median_return)} />
|
|
<Stat label="胜率" value={fmtPct(result.stats.win_rate)} color={statValueColor(result.stats.win_rate)} />
|
|
<Stat label="盈亏比" value={result.stats.profit_factor != null ? Number(result.stats.profit_factor).toFixed(2) : '—'} />
|
|
<Stat label="超额(vs基准)" value={fmtPct(result.stats.excess)} color={statValueColor(result.stats.excess)} />
|
|
<Stat label="夏普" value={result.stats.sharpe != null ? Number(result.stats.sharpe).toFixed(2) : '—'} />
|
|
<Stat label="最大回撤" value={fmtPct(result.stats.max_drawdown)} color={statValueColor(result.stats.max_drawdown)} />
|
|
<Stat label="累计收益" 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.stop_loss != null && (
|
|
<span className="text-[10px] text-secondary">止损 {fmtPct(result.strategy_info.stop_loss)}</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="总收益" value={strategyReturn != null ? fmtPct(strategyReturn) : '—'}
|
|
color={statValueColor(strategyReturn)} />
|
|
<Stat label="年化" value={pick('annual_return') != null ? fmtPct(pick('annual_return') as number) : '—'}
|
|
color={statValueColor(pick('annual_return') as number)} />
|
|
<Stat label="同期上证" value={benchmarkReturn != null ? fmtPct(benchmarkReturn) : '—'}
|
|
color={statValueColor(benchmarkReturn)} />
|
|
<Stat label="超额收益" value={excessReturn != null ? fmtPct(excessReturn) : '—'}
|
|
color={statValueColor(excessReturn)} />
|
|
<Stat label={<SharpeLabel />} value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} />
|
|
<Stat label="最大回撤" value={pick('max_drawdown') != null ? fmtPct(pick('max_drawdown') as number) : '—'}
|
|
color="#34d399" />
|
|
<Stat label="胜率" value={pick('win_rate') != null ? fmtPct(pick('win_rate') as number) : '—'} />
|
|
<Stat label="交易数" value={pick('n_trades') != null ? String(pick('n_trades')) : '—'} />
|
|
{result.stats.full_kind === 'candidate_execution' ? (
|
|
<Stat label="平均持仓" value={pick('avg_duration') != null ? `${Number(pick('avg_duration')).toFixed(1)}天` : '—'} />
|
|
) : (
|
|
<Stat label="最终权益" value={pick('final_equity') != null ? fmtPrice(pick('final_equity') as number) : '—'} />
|
|
)}
|
|
</div>
|
|
</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)} />
|
|
))}
|
|
</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)} />
|
|
))}
|
|
</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" />
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<TradeLegCell trade={t} side="sell" />
|
|
</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} /></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">
|
|
{ADVANCED_TABS.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>
|
|
</div>
|
|
|
|
{settingsTab === 'range' && (
|
|
<ConfigSection title="回测范围" hint={<span className="font-medium text-amber-400">留空 = 全市场</span>}>
|
|
<StockPoolPicker value={symbols} onChange={setSymbols} />
|
|
<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 value = basicFilter[field.key] == null ? '' : Number(basicFilter[field.key]) / scale
|
|
return (
|
|
<label key={field.key} className="block">
|
|
<span className="mb-1 block text-[11px] text-secondary">{field.label}({field.unit})</span>
|
|
<input
|
|
type="number"
|
|
value={value}
|
|
min={0}
|
|
step={field.unit === '%' ? 0.1 : 0.01}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
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>
|
|
<input
|
|
type="number"
|
|
value={scoreMinValue}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
placeholder="不限"
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
updateOverride('score_min', n == null ? null : clamp(n, 0, 100))
|
|
}}
|
|
className={INPUT_CLS}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-[11px] text-secondary">最大评分</span>
|
|
<input
|
|
type="number"
|
|
value={scoreMaxValue}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
placeholder="不限"
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
updateOverride('score_max', n == null ? null : clamp(n, 0, 100))
|
|
}}
|
|
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>
|
|
<input
|
|
type="number"
|
|
value={stopLossPct}
|
|
min={0}
|
|
max={99}
|
|
step={0.5}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
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>
|
|
<input
|
|
type="number"
|
|
value={trailingStopPct}
|
|
min={0.5}
|
|
max={50}
|
|
step={0.5}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
updateOverride('trailing_stop', n == null ? null : -clamp(Math.abs(n), 0.5, 50) / 100)
|
|
}}
|
|
className={INPUT_CLS}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-[11px] text-secondary">回撤止盈启动(%)</span>
|
|
<input
|
|
type="number"
|
|
value={trailingTakeProfitActivatePct}
|
|
min={1}
|
|
max={200}
|
|
step={0.5}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
const next = n == null ? null : clamp(Math.abs(n), 1, 200) / 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>
|
|
<input
|
|
type="number"
|
|
value={trailingTakeProfitDrawdownPct}
|
|
min={0.5}
|
|
max={50}
|
|
step={0.5}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
const activate = numOrNull(trailingTakeProfitActivatePct)
|
|
const maxValue = activate == null ? 50 : Math.min(50, Math.abs(activate))
|
|
updateOverride('trailing_take_profit_drawdown', n == null ? null : clamp(Math.abs(n), 0.5, maxValue) / 100)
|
|
}}
|
|
className={INPUT_CLS}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-[11px] text-secondary">最长持仓(天)</span>
|
|
<input
|
|
type="number"
|
|
value={maxHoldDaysValue}
|
|
min={1}
|
|
step={1}
|
|
onChange={e => {
|
|
const n = numOrNull(e.target.value)
|
|
updateOverride('max_hold_days', n == null ? null : Math.max(1, 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>
|
|
<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>
|
|
)
|
|
}
|