import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { X, Settings2, RotateCcw, Save, ChevronDown, Filter, Star, TrendingUp, Sparkles, Download, Layers, Plus, Trash2 } from 'lucide-react' import { api, type StrategyDetail, type StrategyParamDef, type CompositeChildInfo } from '@/lib/api' import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns' import { color } from '@/lib/colors' import { SignalPicker } from './SignalPicker' import { SignalTriggerActions } from '@/components/signals/SignalTriggerActions' import { Modal } from '@/components/Modal' // 内置列名 → 中文标签 const FIELD_LABEL: Record = {} for (const c of BUILTIN_COLUMNS) { if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label } // enriched 列名别名 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乖离率', }) interface Props { strategyId: string | null onClose: () => void onSaved?: (displayLimit: number | null) => void onAiModify?: () => void onDeleted?: () => void } // ===== 可折叠区域 ===== function Section({ icon: Icon, title, accent, defaultOpen = true, children, extra }: { icon?: React.ComponentType<{ className?: string }> title: string accent?: string defaultOpen?: boolean children: React.ReactNode extra?: React.ReactNode }) { const [open, setOpen] = useState(defaultOpen) return (
{extra &&
{extra}
}
{open && (
{children}
)}
) } // ===== 区间字段(最小 ~ 最大) ===== function RangeField({ label, minVal, maxVal, onMinChange, onMaxChange, unit, step }: { label: string minVal: any maxVal: any onMinChange: (v: any) => void onMaxChange: (v: any) => void unit?: string step?: string }) { return (
{label} onMinChange(e.target.value === '' ? null : Number(e.target.value))} placeholder="最小" step={step} className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> ~ onMaxChange(e.target.value === '' ? null : Number(e.target.value))} placeholder="最大" step={step} className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> {unit && {unit}}
) } // 板块标签 const ALL_BOARDS = ['沪主板', '深主板', '创业板', '科创板', '北交所'] // 策略参数字段 function ParamField({ def, value, onChange }: { def: StrategyParamDef value: any onChange: (v: any) => void }) { if (def.type === 'bool') { const checked = value === true || value === 'true' || value === 'True' return (
{def.label}
) } if (def.type === 'select' && def.options) { return (
{def.label}
) } return (
{def.label} onChange(e.target.value === '' ? def.default : Number(e.target.value))} step={def.step ?? 0.1} min={def.min} max={def.max} className="w-20 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> {def.min != null && def.max != null && ( {def.min}~{def.max} )}
) } // 评分权重字段 function ScoringField({ col, weight, pct, editing, onChange }: { col: string; weight: number; pct: number; editing: boolean; onChange: (v: number) => void }) { return (
{FIELD_LABEL[col] ?? col} {editing ? ( onChange(Number(e.target.value))} min={0} max={100} step={1} className="flex-1 h-1 accent-amber-400 cursor-pointer" /> ) : (
)} {editing ? weight : `${pct}%`}
) } export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) { const [detail, setDetail] = useState(null) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [resetting, setResetting] = useState(false) // 编辑状态 const [strategyName, setStrategyName] = useState('') const [strategyDesc, setStrategyDesc] = useState('') const [basicFilter, setBasicFilter] = useState>({}) const [params, setParams] = useState>({}) const [scoring, setScoring] = useState>({}) const [stopLoss, setStopLoss] = useState(null) const [maxHoldDays, setMaxHoldDays] = useState(null) const [entrySignals, setEntrySignals] = useState([]) const [exitSignals, setExitSignals] = useState([]) const [displayLimit, setDisplayLimit] = useState(null) const [basicFilterEnabled, setBasicFilterEnabled] = useState(true) // 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存) const [compositeChildren, setCompositeChildren] = useState([]) // 可选子策略列表 + 添加面板开关(composite 设置用) const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([]) const [showAddChild, setShowAddChild] = useState(false) const [editingScoring, setEditingScoring] = useState(false) const [deleting, setDeleting] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [deleteError, setDeleteError] = useState('') // 辅助:更新 basicFilter 某个 key const setBF = useCallback((key: string, value: any) => { setBasicFilter(prev => ({ ...prev, [key]: value })) }, []) // 加载策略详情 useEffect(() => { if (!strategyId) return setLoading(true) api.strategyGet(strategyId) .then(d => { setDetail(d) setStrategyName(d.name ?? '') setStrategyDesc(d.description ?? '') // 确保 boards 有默认值 const bf = { ...d.basic_filter } if (!bf.boards) bf.boards = ALL_BOARDS setBasicFilter(bf) setParams(d.params_defaults) setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)]))) setStopLoss(d.stop_loss) setMaxHoldDays(d.max_hold_days) setEntrySignals(d.entry_signals ?? []) setExitSignals(d.exit_signals ?? []) setDisplayLimit(d.display_limit ?? null) setBasicFilterEnabled(d.basic_filter?.enabled !== false) setCompositeChildren(d.composite_children ?? []) // composite 策略: 加载全部可选子策略(排除自身和其他 composite)供添加 if (d.source === 'composite') { api.screenerStrategies().then(data => { setAllStrategies((data.presets ?? []).filter(s => s.id !== strategyId && s.source !== 'composite')) }).catch(() => setAllStrategies([])) } }) .catch(() => setDetail(null)) .finally(() => setLoading(false)) }, [strategyId]) // 叠加策略: 权重归一(总和→1.0) const compositeTotal = compositeChildren.reduce((s, c) => s + (c.weight || 0), 0) const normalizeCompositeWeights = () => { if (compositeTotal <= 0) return setCompositeChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / compositeTotal) * 1000) / 1000 }))) } const removeCompositeChild = (id: string) => { setCompositeChildren(prev => prev.filter(c => c.id !== id)) } const addCompositeChild = (s: { id: string; name: string; source?: string }) => { setCompositeChildren(prev => [...prev, { id: s.id, name: s.name, source: s.source ?? '', weight: 1.0 }]) setShowAddChild(false) } // 保存 const handleSave = async () => { if (!strategyId) return setSaving(true) try { await api.strategySaveConfig(strategyId, { name: strategyName, description: strategyDesc, basic_filter: { ...basicFilter, enabled: basicFilterEnabled }, params, scoring: Object.fromEntries(Object.entries(scoring).map(([k, v]) => [k, +(v / 100).toFixed(4)])), stop_loss: stopLoss, max_hold_days: maxHoldDays, entry_signals: entrySignals, exit_signals: exitSignals, display_limit: displayLimit, // 叠加策略: 子策略权重(composite 专属, 走 override.children 持久化) ...(detail?.source === 'composite' ? { children: compositeChildren.map(c => ({ strategy_id: c.id, weight: c.weight })) } : {}), }) onSaved?.(displayLimit) onClose() } finally { setSaving(false) } } // 重置 const handleReset = async () => { if (!strategyId) return setResetting(true) try { await api.strategyResetConfig(strategyId) // 重新加载默认值 const d = await api.strategyGet(strategyId) setDetail(d) setStrategyName(d.name ?? '') setStrategyDesc(d.description ?? '') const bf = { ...d.basic_filter } if (!bf.boards) bf.boards = ALL_BOARDS setBasicFilter(bf) setParams(d.params_defaults) setScoring(Object.fromEntries(Object.entries(d.scoring).map(([k, v]) => [k, Math.round((v as number) * 100)]))) setStopLoss(d.stop_loss) setMaxHoldDays(d.max_hold_days) setEntrySignals(d.entry_signals ?? []) setExitSignals(d.exit_signals ?? []) setDisplayLimit(d.display_limit ?? null) setBasicFilterEnabled(d.basic_filter?.enabled !== false) setCompositeChildren(d.composite_children ?? []) } finally { setResetting(false) } } const handleDelete = async () => { if (!strategyId) return setDeleting(true) setDeleteError('') try { await api.strategyDelete(strategyId) onDeleted?.() onClose() setShowDeleteConfirm(false) } catch (e: any) { // request() 已弹 toast, 这里再在确认弹窗内显式提示, 并保持弹窗打开让用户知晓删除失败。 setDeleteError(String(e?.message ?? '删除失败,请重试')) } finally { setDeleting(false) } } const handleDownload = async () => { if (!strategyId || !detail || (detail.source !== 'ai' && detail.source !== 'custom')) return const src = await api.strategyGetSource(strategyId) const blob = new Blob([src.code], { type: 'text/x-python;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${strategyId}.py` document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } if (!strategyId) return null return ( <> {/* 标题 */}
{detail?.name ?? strategyId} {detail && {{ builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }[detail.source] ?? detail.source}} {strategyId}
{detail && (detail.source === 'ai' || detail.source === 'custom') && ( )}
{/* 内容 */}
{loading ? (
) : detail ? ( <> {/* 名称 + 描述 + 显示上限 */}
名称 setStrategyName(e.target.value)} className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm font-medium text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
描述 setStrategyDesc(e.target.value)} className="flex-1 h-8 px-3 rounded-lg bg-base border-0 ring-1 ring-border/30 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
显示上限 setDisplayLimit(e.target.value ? Number(e.target.value) : null)} step={1} min={10} max={200} placeholder="不限" className="w-14 h-8 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
{/* 叠加策略: 子策略列表 + 权重(替换三列参数, composite 专属) */} {detail.source === 'composite' ? (() => { const SRC_LABEL: Record = { builtin: '内置', custom: '自定义', ai: 'AI' } const SRC_CLS: Record = { builtin: 'border-accent/25 bg-accent/10 text-accent', custom: 'border-amber-400/25 bg-amber-400/10 text-amber-400', ai: 'border-purple-500/25 bg-purple-500/10 text-purple-400', } const selectedIds = new Set(compositeChildren.map(c => c.id)) const candidates = allStrategies.filter(s => !selectedIds.has(s.id)) return (
子策略与权重 共 {compositeChildren.length} 个 · 权重总和 {compositeTotal.toFixed(2)} {compositeTotal > 0 && Math.abs(compositeTotal - 1) > 0.001 && ( )}
{/* 添加子策略面板 */} {showAddChild && (
{candidates.length === 0 ? (
无可添加的策略
) : candidates.map(s => ( ))}
)} {compositeChildren.length === 0 ? (
暂无子策略, 点击"添加"选择
) : (
{compositeChildren.map((c, i) => (
{i + 1}
{c.name || c.id} {c.source && ( {SRC_LABEL[c.source] ?? c.source} )}
{c.id}
setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseFloat(e.target.value) || 0 } : p))} className="w-16 h-7 px-1.5 rounded-lg bg-base border border-border/40 text-xs font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
))}
)}
提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。
) })() : (
{/* 列1:选股条件 */}
启用基础参数过滤
setBF('price_min', v)} onMaxChange={v => setBF('price_max', v)} unit="元" step="1" /> setBF('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" /> setBF('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" /> setBF('turnover_min', v)} onMaxChange={v => setBF('turnover_max', v)} unit="%" step="0.5" />
板块
{ALL_BOARDS.map(b => { const boards: string[] = basicFilter.boards ?? ALL_BOARDS const active = boards.includes(b) return ( ) })}
ST
{/* 列2:策略参数 */}
{detail.params.length > 0 ? (
{detail.params.map(p => setParams({ ...params, [p.id]: v })} />)}
) : (
无策略参数
)}
{/* 列3:评分 + 交易 */}
{Object.entries(scoring).length > 0 ? (() => { const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1 return (
{Object.entries(scoring).map(([col, w]) => { const pct = Math.round((w / total) * 100) return ( setScoring({ ...scoring, [col]: Math.max(0, v) })} /> ) })}
总和 {editingScoring ? total : '100'} 自动归权计算
) })() :
未配置
}
止损 setStopLoss(e.target.value === '' ? null : Number(e.target.value))} step={0.01} min={-0.5} max={0} className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" /> {stopLoss != null ? `${(stopLoss * 100).toFixed(1)}%` : '—'}
持有 setMaxHoldDays(e.target.value === '' ? null : Number(e.target.value))} step={1} min={1} className="w-16 h-6 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
入场 {entrySignals.length > 0 ? `${entrySignals.length} 个触发器` : '无'} 出场 {exitSignals.length > 0 ? `${exitSignals.length} 个触发器` : '无'}
} >
任一入场点满足即进入候选。
} >
任一出场点满足即触发出场。
出入场触发器保存后对回测和监控生效;选股扫描仍按策略本身的筛选规则,不受此影响。
{detail.alerts.length > 0 && (
{detail.alerts.map((a, i) => (
{a.message} {a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}
))}
)}
)} ) : (
加载失败
)}
{/* 底部按钮 */}
{(detail?.source === 'ai' || detail?.source === 'custom' || detail?.source === 'composite') && ( )}
{(detail?.source === 'ai' || detail?.source === 'custom') && ( )}
{/* 删除确认弹窗 — 必须放 Modal 外: Modal 面板有 backdrop-blur (为 fixed 后代建立定位上下文) + overflow-hidden, 放里面会导致本应全屏居中的确认框相对面板定位并被裁剪/错位。 */} {showDeleteConfirm && ( setShowDeleteConfirm(false)} > e.stopPropagation()} >
!
删除策略
确定要删除「{detail?.name ?? strategyId}」吗?
删除后无法恢复,策略文件、配置和关联数据将被永久清除。
{deleteError && (
{deleteError}
)}
)} ) }