mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
SignalPicker 新增 filterCustomByKind 选项 (默认 true 保持原行为); 监控规则页传 false — 报警语义是"命中即报", 不分入场/出场, 与后端监控引擎按 csg_ 布尔列直接求值对齐; 可选 props 收拢为 options 对象; 自定义信号按钮配色与内置一致, 右上角 PenLine 角标区分
752 lines
41 KiB
TypeScript
752 lines
41 KiB
TypeScript
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<string, string> = {}
|
|
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 (
|
|
<div className="rounded-xl border border-border/15 bg-surface/20 overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3.5 py-2 hover:bg-surface/30 transition-colors">
|
|
<button
|
|
onClick={() => setOpen(v => !v)}
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left cursor-pointer"
|
|
>
|
|
<ChevronDown className={`h-3 w-3 text-muted/40 transition-transform duration-200 ${open ? '' : '-rotate-90'}`} />
|
|
{Icon && <Icon className={`h-3.5 w-3.5 ${accent ?? 'text-muted'}`} />}
|
|
<span className="text-[11px] font-medium text-foreground/70">{title}</span>
|
|
</button>
|
|
{extra && <div className="ml-auto flex items-center gap-1">{extra}</div>}
|
|
</div>
|
|
<AnimatePresence initial={false}>
|
|
{open && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-3.5 pb-3 pt-0.5 space-y-2">
|
|
{children}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
// ===== 区间字段(最小 ~ 最大) =====
|
|
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 (
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{label}</span>
|
|
<input
|
|
type="number"
|
|
value={minVal ?? ''}
|
|
onChange={e => 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"
|
|
/>
|
|
<span className="text-[10px] text-muted">~</span>
|
|
<input
|
|
type="number"
|
|
value={maxVal ?? ''}
|
|
onChange={e => 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 && <span className="text-[10px] text-muted shrink-0">{unit}</span>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 板块标签
|
|
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 (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors duration-200 cursor-pointer ${
|
|
checked ? 'bg-accent' : 'bg-elevated'
|
|
}`}
|
|
aria-pressed={checked}
|
|
>
|
|
<span className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform duration-200 ${
|
|
checked ? 'translate-x-[14px]' : 'translate-x-0.5'
|
|
}`} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
if (def.type === 'select' && def.options) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
|
|
<select
|
|
value={value ?? def.default}
|
|
onChange={e => onChange(e.target.value)}
|
|
className="w-24 px-1.5 py-0.5 rounded bg-base border border-border text-[11px] font-mono text-foreground focus:outline-none focus:border-accent/50"
|
|
>
|
|
{def.options.map(o => <option key={o} value={o}>{o}</option>)}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{def.label}</span>
|
|
<input
|
|
type="number"
|
|
value={value ?? def.default}
|
|
onChange={e => 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 && (
|
|
<span className="text-[10px] text-muted">{def.min}~{def.max}</span>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 评分权重字段
|
|
function ScoringField({ col, weight, pct, editing, onChange }: {
|
|
col: string; weight: number; pct: number; editing: boolean; onChange: (v: number) => void
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">{FIELD_LABEL[col] ?? col}</span>
|
|
{editing ? (
|
|
<input
|
|
type="range"
|
|
value={weight}
|
|
onChange={e => onChange(Number(e.target.value))}
|
|
min={0} max={100} step={1}
|
|
className="flex-1 h-1 accent-amber-400 cursor-pointer"
|
|
/>
|
|
) : (
|
|
<div className="flex-1 h-1.5 bg-elevated rounded-full overflow-hidden">
|
|
<div className="h-full bg-amber-400/70 rounded-full transition-all duration-300" style={{ width: `${Math.min(pct, 100)}%` }} />
|
|
</div>
|
|
)}
|
|
<span className="w-10 text-right text-[10px] font-mono text-muted">{editing ? weight : `${pct}%`}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModify, onDeleted }: Props) {
|
|
const [detail, setDetail] = useState<StrategyDetail | null>(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<Record<string, any>>({})
|
|
const [params, setParams] = useState<Record<string, any>>({})
|
|
const [scoring, setScoring] = useState<Record<string, number>>({})
|
|
const [stopLoss, setStopLoss] = useState<number | null>(null)
|
|
const [maxHoldDays, setMaxHoldDays] = useState<number | null>(null)
|
|
const [entrySignals, setEntrySignals] = useState<string[]>([])
|
|
const [exitSignals, setExitSignals] = useState<string[]>([])
|
|
const [displayLimit, setDisplayLimit] = useState<number | null>(null)
|
|
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
|
|
// 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存)
|
|
const [compositeChildren, setCompositeChildren] = useState<CompositeChildInfo[]>([])
|
|
// 可选子策略列表 + 添加面板开关(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 (
|
|
<>
|
|
<Modal
|
|
onClose={onClose}
|
|
labelledBy="strategy-settings-title"
|
|
overlayClassName="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
panelClassName="w-[980px] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
|
>
|
|
{/* 标题 */}
|
|
<div className="flex items-center justify-between px-5 py-3 border-b border-border/50">
|
|
<div className="flex items-center gap-2.5">
|
|
<Settings2 className="h-4 w-4 text-accent" />
|
|
<span id="strategy-settings-title" className="text-sm font-semibold text-foreground">{detail?.name ?? strategyId}</span>
|
|
{detail && <span className="text-[10px] px-1.5 py-0.5 rounded bg-elevated text-muted">{{ builtin: '内置', custom: '自定义', ai: 'AI', composite: '叠加' }[detail.source] ?? detail.source}</span>}
|
|
<span className="text-[10px] text-muted/40 font-mono">{strategyId}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{detail && (detail.source === 'ai' || detail.source === 'custom') && (
|
|
<button
|
|
aria-label="下载策略"
|
|
title="下载策略"
|
|
onClick={handleDownload}
|
|
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-lg border border-border/60 bg-surface text-xs text-secondary hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
下载策略
|
|
</button>
|
|
)}
|
|
<button aria-label="关闭" onClick={onClose} className="p-1.5 rounded-lg hover:bg-elevated transition-colors cursor-pointer"><X className="h-4 w-4 text-muted" /></button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 内容 */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16"><div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" /></div>
|
|
) : detail ? (
|
|
<>
|
|
{/* 名称 + 描述 + 显示上限 */}
|
|
<div className="flex items-end gap-4">
|
|
<div className="flex-1 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0">名称</span>
|
|
<input type="text" value={strategyName} onChange={e => 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" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-muted/50 uppercase tracking-wider w-8 shrink-0">描述</span>
|
|
<input type="text" value={strategyDesc} onChange={e => 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" />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 pb-0.5 shrink-0">
|
|
<span className="text-[10px] text-muted/50">显示上限</span>
|
|
<input type="number" value={displayLimit ?? ''} onChange={e => 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" />
|
|
<span className="text-[10px] text-muted/50">只</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 叠加策略: 子策略列表 + 权重(替换三列参数, composite 专属) */}
|
|
{detail.source === 'composite' ? (() => {
|
|
const SRC_LABEL: Record<string, string> = { builtin: '内置', custom: '自定义', ai: 'AI' }
|
|
const SRC_CLS: Record<string, string> = {
|
|
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 (
|
|
<div className="rounded-xl border border-teal-500/20 bg-teal-500/5 p-4 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Layers className="h-4 w-4 text-teal-400" />
|
|
<span className="text-sm font-medium text-foreground">子策略与权重</span>
|
|
<span className="text-[10px] text-muted flex items-center gap-1.5">
|
|
共 {compositeChildren.length} 个 · 权重总和 {compositeTotal.toFixed(2)}
|
|
{compositeTotal > 0 && Math.abs(compositeTotal - 1) > 0.001 && (
|
|
<button onClick={normalizeCompositeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2">归一</button>
|
|
)}
|
|
</span>
|
|
<button onClick={() => setShowAddChild(v => !v)} className="ml-auto inline-flex items-center gap-1 h-6 px-2 rounded-lg border border-teal-500/30 bg-teal-500/10 text-[11px] text-teal-400 hover:bg-teal-500/20">
|
|
<Plus className="h-3 w-3" />添加
|
|
</button>
|
|
</div>
|
|
{/* 添加子策略面板 */}
|
|
{showAddChild && (
|
|
<div className="rounded-lg border border-border bg-base/60 p-2 space-y-1 max-h-48 overflow-y-auto">
|
|
{candidates.length === 0 ? (
|
|
<div className="text-[11px] text-muted py-2 text-center">无可添加的策略</div>
|
|
) : candidates.map(s => (
|
|
<button key={s.id} onClick={() => addCompositeChild(s)} className="flex w-full items-center gap-1.5 rounded px-2 py-1 text-left hover:bg-teal-500/10">
|
|
<Plus className="h-3 w-3 shrink-0 text-teal-400" />
|
|
<span className="flex-1 truncate text-xs text-foreground">{s.name}</span>
|
|
{s.source && (
|
|
<span className={`rounded border px-1 text-[8px] ${SRC_CLS[s.source] ?? ''}`}>{SRC_LABEL[s.source] ?? s.source}</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{compositeChildren.length === 0 ? (
|
|
<div className="text-xs text-muted py-4 text-center">暂无子策略, 点击"添加"选择</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{compositeChildren.map((c, i) => (
|
|
<div key={c.id} className="flex items-center gap-2 rounded-lg bg-base/60 px-3 py-2">
|
|
<span className="text-[10px] text-muted/50 font-mono w-5">{i + 1}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs font-medium text-foreground truncate">{c.name || c.id}</span>
|
|
{c.source && (
|
|
<span className={`rounded border px-1 text-[8px] shrink-0 ${SRC_CLS[c.source] ?? ''}`}>{SRC_LABEL[c.source] ?? c.source}</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[10px] text-muted/50 font-mono">{c.id}</div>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<input
|
|
type="number"
|
|
step={0.05}
|
|
min={0}
|
|
value={c.weight}
|
|
onChange={e => 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"
|
|
/>
|
|
<button onClick={() => removeCompositeChild(c.id)} className="text-danger/50 hover:text-danger p-1">
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="text-[10px] text-muted/60 pt-1 border-t border-border/30">
|
|
提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。
|
|
</div>
|
|
</div>
|
|
)
|
|
})()
|
|
: (
|
|
<div className="grid grid-cols-3 gap-5 items-start">
|
|
{/* 列1:选股条件 */}
|
|
<Section icon={Filter} title="基础参数" accent="text-sky-400">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-[10px] text-muted">启用基础参数过滤</span>
|
|
<button onClick={() => setBasicFilterEnabled(v => !v)}
|
|
className={`relative w-8 h-[18px] rounded-full transition-colors ${basicFilterEnabled ? 'bg-sky-500' : 'bg-border'}`}>
|
|
<span className={`absolute top-0.5 w-3.5 h-3.5 rounded-full bg-white transition-transform ${basicFilterEnabled ? 'left-[16px]' : 'left-0.5'}`} />
|
|
</button>
|
|
</div>
|
|
<div className={`space-y-2 transition-opacity duration-200 ${basicFilterEnabled ? '' : 'opacity-25 pointer-events-none'}`}>
|
|
<RangeField label="价格" minVal={basicFilter.price_min} maxVal={basicFilter.price_max} onMinChange={v => setBF('price_min', v)} onMaxChange={v => setBF('price_max', v)} unit="元" step="1" />
|
|
<RangeField label="流通市值" minVal={basicFilter.float_cap_min != null ? basicFilter.float_cap_min / 1e8 : null} maxVal={basicFilter.float_cap_max != null ? basicFilter.float_cap_max / 1e8 : null} onMinChange={v => setBF('float_cap_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('float_cap_max', v != null ? v * 1e8 : null)} unit="亿" step="5" />
|
|
<RangeField label="成交额" minVal={basicFilter.amount_min != null ? basicFilter.amount_min / 1e8 : null} maxVal={basicFilter.amount_max != null ? basicFilter.amount_max / 1e8 : null} onMinChange={v => setBF('amount_min', v != null ? v * 1e8 : null)} onMaxChange={v => setBF('amount_max', v != null ? v * 1e8 : null)} unit="亿" step="0.5" />
|
|
<RangeField label="换手率" minVal={basicFilter.turnover_min} maxVal={basicFilter.turnover_max} onMinChange={v => setBF('turnover_min', v)} onMaxChange={v => setBF('turnover_max', v)} unit="%" step="0.5" />
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-start gap-1.5">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right pt-0.5">板块</span>
|
|
<div className="flex flex-wrap gap-0.5">
|
|
{ALL_BOARDS.map(b => {
|
|
const boards: string[] = basicFilter.boards ?? ALL_BOARDS
|
|
const active = boards.includes(b)
|
|
return (
|
|
<button key={b} onClick={() => { const cur: string[] = basicFilter.boards ?? ALL_BOARDS; const next = active ? cur.filter(x => x !== b) : [...cur, b]; setBF('boards', next.length === 0 ? ALL_BOARDS : next) }}
|
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${active ? `${color.select.border} ${color.select.bgLight} ${color.select.text}` : `border-border bg-base text-muted ${color.select.borderHover}`}`}>{b}</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[11px] text-secondary w-16 shrink-0 text-right">ST</span>
|
|
<button onClick={() => setBF('exclude_st', !basicFilter.exclude_st)}
|
|
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition-colors cursor-pointer ${basicFilter.exclude_st ? 'border-danger/40 bg-danger/10 text-danger' : 'border-border bg-base text-muted hover:border-danger/30'}`}>{basicFilter.exclude_st ? '排除' : '包含'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
{/* 列2:策略参数 */}
|
|
<div className="space-y-3">
|
|
{detail.params.length > 0 ? (
|
|
<Section icon={Settings2} title="策略参数" accent="text-muted">
|
|
<div className="space-y-1.5">
|
|
{detail.params.map(p => <ParamField key={p.id} def={p} value={params[p.id]} onChange={v => setParams({ ...params, [p.id]: v })} />)}
|
|
</div>
|
|
</Section>
|
|
) : (
|
|
<div className="rounded-xl border border-border/15 bg-surface/20 px-3.5 py-4 text-[11px] text-muted/50 text-center">无策略参数</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 列3:评分 + 交易 */}
|
|
<div className="space-y-3">
|
|
<Section icon={Star} title="评分权重" accent="text-amber-400">
|
|
{Object.entries(scoring).length > 0 ? (() => {
|
|
const total = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
|
return (
|
|
<div className="space-y-2">
|
|
{Object.entries(scoring).map(([col, w]) => {
|
|
const pct = Math.round((w / total) * 100)
|
|
return (
|
|
<ScoringField key={col} col={col} weight={w} pct={pct}
|
|
editing={editingScoring}
|
|
onChange={v => setScoring({ ...scoring, [col]: Math.max(0, v) })} />
|
|
)
|
|
})}
|
|
<div className="flex items-center justify-between pt-1.5 border-t border-border/10">
|
|
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
|
<span>总和</span>
|
|
<span className={`font-mono font-medium text-xs ${editingScoring ? (total === 100 ? color.ok : color.scoreWarn) : color.ok}`}>{editingScoring ? total : '100'}</span>
|
|
<span className="text-muted/40">自动归权计算</span>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
if (editingScoring) {
|
|
// 确认:归一化到 100
|
|
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
|
const norm = Object.fromEntries(
|
|
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
|
|
)
|
|
// 修正舍入误差
|
|
const newSum = Object.values(norm).reduce((a: number, b: number) => a + b, 0)
|
|
if (newSum !== 100) {
|
|
const keys = Object.keys(norm)
|
|
norm[keys[0]] += (100 - newSum)
|
|
}
|
|
setScoring(norm)
|
|
} else {
|
|
// 进入编辑:展开为 0-100 范围
|
|
const sum = Object.values(scoring).reduce((a: number, b: number) => a + b, 0) || 1
|
|
setScoring(Object.fromEntries(
|
|
Object.entries(scoring).map(([k, v]) => [k, Math.round((v / sum) * 100)])
|
|
))
|
|
}
|
|
setEditingScoring(v => !v)
|
|
}}
|
|
className="text-[10px] text-accent/80 hover:text-accent cursor-pointer"
|
|
>{editingScoring ? '确定' : '设置'}</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})() : <div className="text-[11px] text-muted">未配置</div>}
|
|
</Section>
|
|
|
|
<Section icon={TrendingUp} title="交易参数" accent="text-emerald-400">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[11px] text-secondary w-12 shrink-0">止损</span>
|
|
<input type="number" value={stopLoss ?? ''} onChange={e => 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" />
|
|
<span className="text-[10px] text-muted">{stopLoss != null ? `${(stopLoss * 100).toFixed(1)}%` : '—'}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-[11px] text-secondary w-12 shrink-0">持有</span>
|
|
<input type="number" value={maxHoldDays ?? ''} onChange={e => 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" />
|
|
<span className="text-[10px] text-muted">天</span>
|
|
</div>
|
|
<div className="text-[11px] text-muted pt-1 border-t border-border/10">
|
|
<span className="text-secondary">入场 </span><span className="text-foreground/70">{entrySignals.length > 0 ? `${entrySignals.length} 个触发器` : '无'}</span>
|
|
<span className="text-secondary ml-3">出场 </span><span className="text-foreground/70">{exitSignals.length > 0 ? `${exitSignals.length} 个触发器` : '无'}</span>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section
|
|
icon={TrendingUp}
|
|
title="入场触发器"
|
|
accent="text-accent"
|
|
defaultOpen={false}
|
|
extra={<SignalTriggerActions kind="entry" signals={entrySignals} onChange={setEntrySignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
|
|
>
|
|
<SignalPicker signals={entrySignals} onChange={setEntrySignals} kind="entry" options={{ variant: 'dialog' }} />
|
|
<div className="text-[10px] leading-4 text-muted/70">任一入场点满足即进入候选。</div>
|
|
</Section>
|
|
|
|
<Section
|
|
icon={TrendingUp}
|
|
title="出场触发器"
|
|
accent="text-warning"
|
|
defaultOpen={false}
|
|
extra={<SignalTriggerActions kind="exit" signals={exitSignals} onChange={setExitSignals} buttonClassName="rounded-md border border-border bg-base p-1 text-muted transition-colors cursor-pointer" iconClassName="h-3 w-3" />}
|
|
>
|
|
<SignalPicker signals={exitSignals} onChange={setExitSignals} kind="exit" options={{ variant: 'dialog' }} />
|
|
<div className="text-[10px] leading-4 text-muted/70">任一出场点满足即触发出场。</div>
|
|
</Section>
|
|
|
|
<div className="rounded-xl border border-amber-400/20 bg-amber-400/[0.04] px-3 py-2 text-[10px] leading-4 text-muted">
|
|
出入场触发器保存后对<b className="text-secondary">回测和监控</b>生效;选股扫描仍按策略本身的筛选规则,不受此影响。
|
|
</div>
|
|
|
|
{detail.alerts.length > 0 && (
|
|
<Section icon={Settings2} title="提醒" accent="text-muted">
|
|
<div className="space-y-1">
|
|
{detail.alerts.map((a, i) => (
|
|
<div key={i} className="text-[10px] text-secondary">{a.message} <span className="text-muted font-mono">{a.op ? `${FIELD_LABEL[a.field] ?? a.field} ${a.op} ${a.value}` : FIELD_LABEL[a.field] ?? a.field}</span></div>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center py-16 text-sm text-muted">加载失败</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 底部按钮 */}
|
|
<div className="flex items-center justify-between px-5 py-3 border-t border-border/50 bg-surface/50">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={handleReset} disabled={resetting}
|
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-border bg-surface text-xs text-secondary hover:text-danger hover:border-danger/30 transition-colors cursor-pointer disabled:opacity-50">
|
|
<RotateCcw className="h-3.5 w-3.5" />{resetting ? '重置中…' : '重置默认'}
|
|
</button>
|
|
{(detail?.source === 'ai' || detail?.source === 'custom' || detail?.source === 'composite') && (
|
|
<button onClick={() => { setDeleteError(''); setShowDeleteConfirm(true) }}
|
|
className="text-[10px] text-danger hover:text-danger/80 transition-colors">删除策略</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{(detail?.source === 'ai' || detail?.source === 'custom') && (
|
|
<button onClick={onAiModify}
|
|
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-lg border border-amber-400/30 bg-amber-400/8 text-amber-400 text-xs font-medium hover:bg-amber-400/15 transition-colors cursor-pointer">
|
|
<Sparkles className="h-3.5 w-3.5" />AI 修改
|
|
</button>
|
|
)}
|
|
<button onClick={handleSave} disabled={saving}
|
|
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-lg bg-accent text-white text-xs font-semibold hover:bg-accent/90 transition-colors cursor-pointer disabled:opacity-50">
|
|
<Save className="h-3.5 w-3.5" />{saving ? '保存中…' : '保存设置'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* 删除确认弹窗 — 必须放 Modal 外: Modal 面板有 backdrop-blur (为 fixed 后代建立定位上下文)
|
|
+ overflow-hidden, 放里面会导致本应全屏居中的确认框相对面板定位并被裁剪/错位。 */}
|
|
{showDeleteConfirm && (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
onClick={() => setShowDeleteConfirm(false)}
|
|
>
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
|
|
className="w-[380px] bg-surface border border-border/50 rounded-2xl shadow-2xl p-6"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div className="text-center space-y-3">
|
|
<div className="w-10 h-10 rounded-full bg-danger/10 flex items-center justify-center mx-auto">
|
|
<span className="text-danger text-lg">!</span>
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-semibold text-foreground">删除策略</div>
|
|
<div className="text-xs text-muted mt-1">确定要删除「{detail?.name ?? strategyId}」吗?</div>
|
|
</div>
|
|
<div className="text-[11px] text-danger/70 bg-danger/[0.04] rounded-lg px-3 py-2 border border-danger/10">
|
|
删除后无法恢复,策略文件、配置和关联数据将被永久清除。
|
|
</div>
|
|
{deleteError && (
|
|
<div className="text-[11px] text-danger bg-danger/10 rounded-lg px-3 py-2 border border-danger/20">
|
|
{deleteError}
|
|
</div>
|
|
)}
|
|
<div className="flex gap-2 pt-2">
|
|
<button onClick={() => setShowDeleteConfirm(false)}
|
|
className="flex-1 h-8 rounded-lg border border-border text-xs text-secondary hover:text-foreground">取消</button>
|
|
<button onClick={handleDelete} disabled={deleting}
|
|
className="flex-1 h-8 rounded-lg bg-danger text-white text-xs font-medium hover:bg-danger/90 disabled:opacity-50">
|
|
{deleting ? '删除中...' : '确认删除'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)}
|
|
</>
|
|
|
|
)
|
|
}
|