mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(screener): 叠加策略创建与配置体验优化
- 新建默认交集合并模式, 选项顺序对调; 可选策略列表加高 - 策略名称默认子策略名用 + 连接, 手动编辑后停止跟随 - 子策略权重改为滑块 (0-100 百分比), 保存时自动按比例归一, 移除手动归一按钮 - 子策略名可点击弹出其配置编辑 (创建对话框与配置对话框均支持) - 策略池: 叠加策略并入自定义分组并标记「叠加」徽标, 修复误显示为内置 - 创建策略/修改策略池后自动扫描新增日线策略, 免手动刷新
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X, Layers, Plus, Loader2, Search } from 'lucide-react'
|
||||
import { X, Layers, Plus, Loader2, Search, Settings2 } from 'lucide-react'
|
||||
import { api, type ScreenerStrategy } from '@/lib/api'
|
||||
import { toPercentages, normalizeWeights } from '@/lib/weights'
|
||||
import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDialog'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
@@ -26,10 +28,12 @@ const BADGE_CLS: Record<string, string> = {
|
||||
export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategyId }: Props) {
|
||||
const isEdit = !!editStrategyId
|
||||
const [name, setName] = useState('')
|
||||
// 用户手动编辑过名称后停止自动生成(子策略名用 + 连接)
|
||||
const [nameDirty, setNameDirty] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
const [strategyId, setStrategyId] = useState('')
|
||||
const [children, setChildren] = useState<ChildItem[]>([])
|
||||
const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('union')
|
||||
const [mergeMode, setMergeMode] = useState<'union' | 'intersect'>('intersect')
|
||||
const [minConfirm, setMinConfirm] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -40,6 +44,19 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
// 拉取所有可用子策略(排除 composite 自身)
|
||||
const [available, setAvailable] = useState<ScreenerStrategy[]>([])
|
||||
const [loadingList, setLoadingList] = useState(false)
|
||||
// 点击子策略名打开其配置编辑
|
||||
const [editingChildId, setEditingChildId] = useState<string | null>(null)
|
||||
|
||||
const loadAvailable = useCallback(() => {
|
||||
setLoadingList(true)
|
||||
api.screenerStrategies()
|
||||
.then(data => {
|
||||
// 排除 composite 策略(不能嵌套)
|
||||
setAvailable((data.presets ?? []).filter(s => s.source !== 'composite'))
|
||||
})
|
||||
.catch(() => setAvailable([]))
|
||||
.finally(() => setLoadingList(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -49,18 +66,13 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
// 创建模式自动生成 ID(composite_ + 时间戳), 编辑模式用现有 ID
|
||||
setStrategyId(isEdit ? (editStrategyId ?? '') : `composite_${Date.now().toString(36)}`)
|
||||
setChildren([])
|
||||
setMergeMode('union')
|
||||
setMergeMode('intersect')
|
||||
setMinConfirm(0)
|
||||
setError('')
|
||||
setSearch('')
|
||||
setLoadingList(true)
|
||||
api.screenerStrategies()
|
||||
.then(data => {
|
||||
// 排除 composite 策略(不能嵌套)
|
||||
setAvailable((data.presets ?? []).filter(s => s.source !== 'composite'))
|
||||
})
|
||||
.catch(() => setAvailable([]))
|
||||
.finally(() => setLoadingList(false))
|
||||
setNameDirty(isEdit)
|
||||
setEditingChildId(null)
|
||||
loadAvailable()
|
||||
// 编辑模式: 加载现有配置回显
|
||||
if (isEdit && editStrategyId) {
|
||||
api.strategyGet(editStrategyId)
|
||||
@@ -70,12 +82,14 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
setMergeMode((detail.params_defaults?.merge_mode as 'union' | 'intersect') ?? 'union')
|
||||
setMinConfirm(detail.params_defaults?.min_confirm ?? 0)
|
||||
if (detail.composite_children) {
|
||||
setChildren(detail.composite_children.map(c => ({ strategy_id: c.id, weight: c.weight })))
|
||||
// 存储的小数权重 → 滑块百分比口径
|
||||
const pcts = toPercentages(detail.composite_children.map(c => c.weight))
|
||||
setChildren(detail.composite_children.map((c, i) => ({ strategy_id: c.id, weight: pcts[i] })))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [open, isEdit, editStrategyId])
|
||||
}, [open, isEdit, editStrategyId, loadAvailable])
|
||||
|
||||
const filteredAvailable = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase()
|
||||
@@ -88,25 +102,33 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
}, [available, children, search])
|
||||
|
||||
const addChild = (s: ScreenerStrategy) => {
|
||||
setChildren(prev => [...prev, { strategy_id: s.id, weight: 1.0 }])
|
||||
// 首个子策略独占 100%, 后续默认 10% (与因子编辑口径一致); 保存时自动按比例归一
|
||||
setChildren(prev => [...prev, { strategy_id: s.id, weight: prev.length === 0 ? 100 : 10 }])
|
||||
}
|
||||
const removeChild = (id: string) => {
|
||||
setChildren(prev => prev.filter(c => c.strategy_id !== id))
|
||||
}
|
||||
|
||||
// 未手动命名时, 默认名称跟随子策略: 多个策略名用 + 连接
|
||||
useEffect(() => {
|
||||
if (nameDirty) return
|
||||
if (children.length === 0) {
|
||||
setName('')
|
||||
return
|
||||
}
|
||||
const names = children.map(c => available.find(a => a.id === c.strategy_id)?.name ?? c.strategy_id)
|
||||
setName(names.join('+'))
|
||||
}, [children, nameDirty, available])
|
||||
const updateWeight = (id: string, weight: number) => {
|
||||
setChildren(prev => prev.map(c => c.strategy_id === id ? { ...c, weight } : c))
|
||||
}
|
||||
|
||||
const totalWeight = useMemo(
|
||||
// 滑块百分比总和; 允许 ≠100 (黄色提示), 保存时自动按比例归一, 无需手动操作
|
||||
const totalPct = useMemo(
|
||||
() => children.reduce((sum, c) => sum + (c.weight || 0), 0),
|
||||
[children],
|
||||
)
|
||||
|
||||
const normalizeWeights = () => {
|
||||
if (totalWeight <= 0) return
|
||||
setChildren(prev => prev.map(c => ({ ...c, weight: Math.round((c.weight / totalWeight) * 1000) / 1000 })))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setError('')
|
||||
if (!name.trim()) {
|
||||
@@ -119,11 +141,12 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const normalized = normalizeWeights(children.map(c => c.weight))
|
||||
const result = await api.strategySaveComposite({
|
||||
strategy_id: isEdit ? (editStrategyId ?? '') : strategyId.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
children: children.map(c => ({ strategy_id: c.strategy_id, weight: c.weight })),
|
||||
children: children.map((c, i) => ({ strategy_id: c.strategy_id, weight: normalized[i] })),
|
||||
merge_mode: mergeMode,
|
||||
min_confirm: minConfirm,
|
||||
mode: isEdit ? 'update' : 'create',
|
||||
@@ -137,7 +160,8 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@@ -191,7 +215,7 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
<label className="mb-1 block text-xs text-muted">策略名称</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onChange={e => { setName(e.target.value); setNameDirty(true) }}
|
||||
placeholder="我的叠加策略"
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
@@ -216,8 +240,8 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
onChange={e => setMergeMode(e.target.value as 'union' | 'intersect')}
|
||||
className="w-full rounded-btn border border-border bg-elevated px-2 py-1.5 text-xs text-foreground"
|
||||
>
|
||||
<option value="union">并集(任一子策略命中即入选)</option>
|
||||
<option value="intersect">交集(至少 N 个子策略同时命中)</option>
|
||||
<option value="union">并集(任一子策略命中即入选)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -240,9 +264,12 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
子策略({children.length})
|
||||
</label>
|
||||
<span className="text-[10px] text-muted flex items-center gap-1.5">
|
||||
权重总和: {totalWeight.toFixed(2)}
|
||||
{totalWeight > 0 && Math.abs(totalWeight - 1) > 0.001 && (
|
||||
<button onClick={normalizeWeights} className="text-teal-400 hover:text-teal-300 underline underline-offset-2">归一</button>
|
||||
权重
|
||||
<span className={`font-mono ${children.length > 0 && totalPct !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{totalPct}%
|
||||
</span>
|
||||
{children.length > 0 && totalPct !== 100 && (
|
||||
<span className="text-amber-400/60">(保存时自动按比例归一)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -256,20 +283,31 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
const s = available.find(a => a.id === c.strategy_id)
|
||||
return (
|
||||
<div key={c.strategy_id} className="flex items-center gap-2 rounded-btn border border-border bg-elevated px-2 py-1.5">
|
||||
<span className="flex-1 truncate text-xs text-foreground">{s?.name ?? c.strategy_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingChildId(c.strategy_id)}
|
||||
title="点击编辑该子策略的配置"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 text-left text-xs text-foreground transition-colors hover:text-accent cursor-pointer"
|
||||
>
|
||||
<span className="truncate">{s?.name ?? c.strategy_id}</span>
|
||||
<Settings2 className="h-3 w-3 shrink-0 text-muted/50" />
|
||||
</button>
|
||||
{s?.source && (
|
||||
<span className={`rounded border px-1 text-[8px] ${BADGE_CLS[s.source] ?? ''}`}>
|
||||
{SRC_MAP[s.source] ?? s.source}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
step={0.05}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={c.weight}
|
||||
onChange={e => updateWeight(c.strategy_id, parseFloat(e.target.value) || 0)}
|
||||
className="w-16 rounded border border-border bg-base px-1.5 py-0.5 text-[11px] text-foreground"
|
||||
onChange={e => updateWeight(c.strategy_id, parseInt(e.target.value) || 0)}
|
||||
className="h-1 w-24 cursor-pointer accent-teal-400"
|
||||
aria-label={`${s?.name ?? c.strategy_id}权重`}
|
||||
/>
|
||||
<span className="w-9 shrink-0 text-right font-mono text-[10px] text-muted">{Math.round(c.weight)}%</span>
|
||||
<button onClick={() => removeChild(c.strategy_id)} className="text-danger/60 hover:text-danger">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -291,7 +329,7 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
className="w-full rounded-btn border border-border bg-elevated py-1.5 pl-7 pr-2 text-xs text-foreground placeholder:text-muted/40"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto rounded-btn border border-border bg-elevated p-1.5">
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto rounded-btn border border-border bg-elevated p-1.5">
|
||||
{loadingList && (
|
||||
<div className="flex items-center justify-center gap-1.5 py-3 text-xs text-muted">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 加载中
|
||||
@@ -346,6 +384,20 @@ export function CompositeStrategyDialog({ open, onClose, onSavedId, editStrategy
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 子策略配置编辑: 点击已选子策略名打开, 渲染在后覆盖于叠加对话框之上 */}
|
||||
<StrategySettingsDialog
|
||||
strategyId={editingChildId}
|
||||
onClose={() => setEditingChildId(null)}
|
||||
onSaved={() => loadAvailable()}
|
||||
onDeleted={() => {
|
||||
// 子策略被删除: 从已选列表移除并刷新可选列表
|
||||
setChildren(prev => prev.filter(c => c.strategy_id !== editingChildId))
|
||||
setEditingChildId(null)
|
||||
loadAvailable()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const SOURCE_CLS: Record<string, string> = {
|
||||
builtin: 'bg-accent/10 text-accent border-accent/20',
|
||||
custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
|
||||
ai: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
// 叠加策略归入「自定义」分组展示, 徽标与 StrategyCard 一致用 teal 区分
|
||||
composite: 'bg-teal-500/10 text-teal-400 border-teal-500/30',
|
||||
invalid: 'bg-danger/10 text-danger border-danger/20',
|
||||
}
|
||||
|
||||
@@ -21,6 +23,7 @@ const SOURCE_LABEL: Record<string, string> = {
|
||||
builtin: '内置',
|
||||
custom: '自定义',
|
||||
ai: 'AI',
|
||||
composite: '叠加',
|
||||
invalid: '失效',
|
||||
}
|
||||
|
||||
@@ -98,9 +101,12 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
|
||||
[allStrategies]
|
||||
)
|
||||
|
||||
// 按 Tab 分组过滤待选
|
||||
// 按 Tab 分组过滤待选; 叠加策略(composite)并入「自定义」分组
|
||||
const filteredAvailable = useMemo(() => {
|
||||
if (activeTab === 'all') return available
|
||||
if (activeTab === 'custom') {
|
||||
return available.filter(s => s.source === 'custom' || s.source === 'composite')
|
||||
}
|
||||
return available.filter(s => s.source === activeTab)
|
||||
}, [available, activeTab])
|
||||
|
||||
@@ -242,7 +248,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
|
||||
{TABS.map(tab => {
|
||||
const count = tab.id === 'all'
|
||||
? available.length
|
||||
: available.filter(s => s.source === tab.id).length
|
||||
: tab.id === 'custom'
|
||||
? available.filter(s => s.source === 'custom' || s.source === 'composite').length
|
||||
: available.filter(s => s.source === tab.id).length
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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, type ScoringDirection } from '@/lib/api'
|
||||
import { toPercentages, normalizeWeights } from '@/lib/weights'
|
||||
import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
|
||||
import { color } from '@/lib/colors'
|
||||
import { SignalPicker } from './SignalPicker'
|
||||
@@ -195,6 +196,8 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
const [basicFilterEnabled, setBasicFilterEnabled] = useState(true)
|
||||
// 叠加策略: 子策略列表与权重(composite 专属, 编辑权重后随 override 保存)
|
||||
const [compositeChildren, setCompositeChildren] = useState<CompositeChildInfo[]>([])
|
||||
// 点击子策略名打开其配置编辑(composite 专属; 子策略必非 composite, 不会再嵌套)
|
||||
const [editingChildId, setEditingChildId] = useState<string | null>(null)
|
||||
// 可选子策略列表 + 添加面板开关(composite 设置用)
|
||||
const [allStrategies, setAllStrategies] = useState<{ id: string; name: string; source?: string }[]>([])
|
||||
const [showAddChild, setShowAddChild] = useState(false)
|
||||
@@ -210,6 +213,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
// 加载策略详情
|
||||
useEffect(() => {
|
||||
if (!strategyId) return
|
||||
setEditingChildId(null)
|
||||
setLoading(true)
|
||||
api.strategyGet(strategyId)
|
||||
.then(d => {
|
||||
@@ -229,7 +233,12 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
setExitSignals(d.exit_signals ?? [])
|
||||
setDisplayLimit(d.display_limit ?? null)
|
||||
setBasicFilterEnabled(d.basic_filter?.enabled !== false)
|
||||
setCompositeChildren(d.composite_children ?? [])
|
||||
setCompositeChildren((() => {
|
||||
// 存储的小数权重 → 滑块百分比口径
|
||||
const list = d.composite_children ?? []
|
||||
const pcts = toPercentages(list.map(c => c.weight))
|
||||
return list.map((c, i) => ({ ...c, weight: pcts[i] }))
|
||||
})())
|
||||
// composite 策略: 加载全部可选子策略(排除自身和其他 composite)供添加
|
||||
if (d.source === 'composite') {
|
||||
api.screenerStrategies().then(data => {
|
||||
@@ -241,17 +250,14 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
.finally(() => setLoading(false))
|
||||
}, [strategyId])
|
||||
|
||||
// 叠加策略: 权重归一(总和→1.0)
|
||||
// 叠加策略: 滑块百分比口径, 允许总和 ≠100, 保存时自动按比例归一
|
||||
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 }])
|
||||
// 首个子策略独占 100%, 后续默认 10% (与因子编辑口径一致)
|
||||
setCompositeChildren(prev => [...prev, { id: s.id, name: s.name, source: s.source ?? '', weight: prev.length === 0 ? 100 : 10 }])
|
||||
setShowAddChild(false)
|
||||
}
|
||||
|
||||
@@ -277,7 +283,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
display_limit: displayLimit,
|
||||
// 叠加策略: 子策略权重(composite 专属, 走 override.children 持久化)
|
||||
...(detail?.source === 'composite'
|
||||
? { children: compositeChildren.map(c => ({ strategy_id: c.id, weight: c.weight })) }
|
||||
? { children: (() => {
|
||||
// 滑块百分比 → 归一小数权重再持久化
|
||||
const normalized = normalizeWeights(compositeChildren.map(c => c.weight))
|
||||
return compositeChildren.map((c, i) => ({ strategy_id: c.id, weight: normalized[i] }))
|
||||
})() }
|
||||
: {}),
|
||||
})
|
||||
onSaved?.(displayLimit)
|
||||
@@ -350,7 +360,11 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
onClose={onClose}
|
||||
onClose={() => {
|
||||
// 子策略编辑弹窗打开期间(Esc 会同时到达两层的 document 监听), 只关最上层的子编辑
|
||||
if (editingChildId) return
|
||||
onClose()
|
||||
}}
|
||||
labelledBy="strategy-settings-title"
|
||||
overlayClassName="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||
panelClassName="w-[1200px] max-w-[95vw] max-h-[88vh] bg-surface/95 backdrop-blur-xl border border-border/50 rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
||||
@@ -423,9 +437,12 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
<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>
|
||||
共 {compositeChildren.length} 个 · 权重
|
||||
<span className={`font-mono ${compositeChildren.length > 0 && compositeTotal !== 100 ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||
{compositeTotal}%
|
||||
</span>
|
||||
{compositeChildren.length > 0 && compositeTotal !== 100 && (
|
||||
<span className="text-amber-400/60">(保存时自动按比例归一)</span>
|
||||
)}
|
||||
</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">
|
||||
@@ -457,22 +474,32 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingChildId(c.id)}
|
||||
title="点击编辑该子策略的配置"
|
||||
className="truncate text-left text-xs font-medium text-foreground transition-colors hover:text-accent cursor-pointer"
|
||||
>
|
||||
{c.name || c.id}
|
||||
</button>
|
||||
{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">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
step={0.05}
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
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"
|
||||
onChange={e => setCompositeChildren(prev => prev.map((p, j) => j === i ? { ...p, weight: parseInt(e.target.value) || 0 } : p))}
|
||||
className="h-1 w-24 cursor-pointer accent-teal-400"
|
||||
aria-label={`${c.name || c.id}权重`}
|
||||
/>
|
||||
<span className="w-9 text-right font-mono text-[10px] text-muted">{Math.round(c.weight)}%</span>
|
||||
<button onClick={() => removeCompositeChild(c.id)} className="text-danger/50 hover:text-danger p-1">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -482,7 +509,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-muted/60 pt-1 border-t border-border/30">
|
||||
提示: 权重建议归一为 1.0; 修改后点底部"保存设置"生效。
|
||||
提示: 权重按相对比例生效, 保存时自动归一; 修改后点底部"保存设置"生效。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -623,7 +650,7 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(detail?.source === 'ai' || detail?.source === 'custom') && (
|
||||
{onAiModify && (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 修改
|
||||
@@ -680,6 +707,26 @@ export function StrategySettingsDialog({ strategyId, onClose, onSaved, onAiModif
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{/* 子策略配置编辑 — 同删除确认弹窗一样必须放 Modal 外 (面板 backdrop-blur 会为
|
||||
fixed 后代建立定位上下文)。渲染在主 Modal 之后, 同 z-50 自然覆盖其上。 */}
|
||||
<StrategySettingsDialog
|
||||
strategyId={editingChildId}
|
||||
onClose={() => setEditingChildId(null)}
|
||||
onSaved={() => {
|
||||
// 子策略可能改名: 拉最新名称同步到列表 (参数 override 按策略 ID 生效, 无需重建叠加)
|
||||
if (!editingChildId) return
|
||||
api.strategyGet(editingChildId)
|
||||
.then(d => setCompositeChildren(prev =>
|
||||
prev.map(c => c.id === editingChildId ? { ...c, name: d.name ?? c.name } : c),
|
||||
))
|
||||
.catch(() => {})
|
||||
}}
|
||||
onDeleted={() => {
|
||||
setCompositeChildren(prev => prev.filter(c => c.id !== editingChildId))
|
||||
setEditingChildId(null)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 子策略权重滑块的百分比互转 — 与因子评分编辑器 (ScoringEditor) 同一套交互口径:
|
||||
* 滑块按 0-100 百分比自由拖动, 总和允许 ≠100 (UI 颜色提示), 保存时自动按比例归一。
|
||||
*
|
||||
* 与因子版的差异: 子策略权重为 0 仍是有效成员 (保留在列表, 不参与融合),
|
||||
* 因此归一时不过滤 0 项; 全部为 0 时退化为均等 (与后端 _effective_weights
|
||||
* total_w<=0 的均等语义一致)。
|
||||
*/
|
||||
|
||||
/** 任意正权重数组 → 合计恰为 100 的整数百分比 (最大余数法分配残差)。 */
|
||||
export function toPercentages(weights: number[]): number[] {
|
||||
const values = weights.map(w => Math.max(0, Number(w) || 0))
|
||||
const total = values.reduce((sum, v) => sum + v, 0)
|
||||
if (total <= 0) return values.map(() => 0)
|
||||
const exact = values.map(v => (v / total) * 100)
|
||||
const floors = exact.map(v => Math.floor(v))
|
||||
let remaining = 100 - floors.reduce((sum, v) => sum + v, 0)
|
||||
const order = exact
|
||||
.map((v, i) => ({ i, rem: v - Math.floor(v) }))
|
||||
.sort((a, b) => b.rem - a.rem || a.i - b.i)
|
||||
for (const { i } of order) {
|
||||
if (remaining <= 0) break
|
||||
floors[i] += 1
|
||||
remaining -= 1
|
||||
}
|
||||
return floors
|
||||
}
|
||||
|
||||
/** 滑块百分比 → 归一小数权重 (合计=1); 保留全部成员, 全 0 时均等。 */
|
||||
export function normalizeWeights(pcts: number[]): number[] {
|
||||
const values = pcts.map(w => Math.max(0, Number(w) || 0))
|
||||
const total = values.reduce((sum, v) => sum + v, 0)
|
||||
if (total <= 0) return values.map(() => +(1 / values.length).toFixed(6))
|
||||
return values.map(v => +(v / total).toFixed(6))
|
||||
}
|
||||
@@ -1123,6 +1123,14 @@ export function Screener() {
|
||||
<StrategyPoolDialog
|
||||
pool={pool}
|
||||
onConfirm={(newPool) => {
|
||||
// 新增的日线策略立即自动扫描, 免去手动点刷新; 纯排序/删除不重跑
|
||||
if (assetType === 'stock') {
|
||||
const prev = new Set(pool)
|
||||
const addedDaily = newPool.filter(
|
||||
id => !prev.has(id) && !(strategyMap.get(id)?.timeframes?.includes('1m') ?? false),
|
||||
)
|
||||
if (addedDaily.length > 0) requestRunAll({ date: asOf || undefined, strategyIds: addedDaily })
|
||||
}
|
||||
reorderPool(newPool)
|
||||
}}
|
||||
onClose={() => setShowPoolDialog(false)}
|
||||
@@ -1144,6 +1152,11 @@ export function Screener() {
|
||||
throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)
|
||||
}
|
||||
addToPool(id)
|
||||
// 新建策略为日线时立即扫描, 免去手动点刷新 (分钟策略仍手动单跑)
|
||||
const preset = data.presets.find(s => s.id === id)
|
||||
if (assetType === 'stock' && preset && !preset.timeframes?.includes('1m')) {
|
||||
requestRunAll({ date: asOf || undefined, strategyIds: [id] })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1151,8 +1164,13 @@ export function Screener() {
|
||||
open={showComposite}
|
||||
onClose={() => setShowComposite(false)}
|
||||
onSavedId={async id => {
|
||||
await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
|
||||
const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
|
||||
addToPool(id)
|
||||
// 新建叠加策略为日线时立即扫描, 免去手动点刷新 (分钟策略仍手动单跑)
|
||||
const preset = data.presets.find(s => s.id === id)
|
||||
if (assetType === 'stock' && preset && !preset.timeframes?.includes('1m')) {
|
||||
requestRunAll({ date: asOf || undefined, strategyIds: [id] })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user