feat(screener): 叠加策略创建与配置体验优化

- 新建默认交集合并模式, 选项顺序对调; 可选策略列表加高
- 策略名称默认子策略名用 + 连接, 手动编辑后停止跟随
- 子策略权重改为滑块 (0-100 百分比), 保存时自动按比例归一, 移除手动归一按钮
- 子策略名可点击弹出其配置编辑 (创建对话框与配置对话框均支持)
- 策略池: 叠加策略并入自定义分组并标记「叠加」徽标, 修复误显示为内置
- 创建策略/修改策略池后自动扫描新增日线策略, 免手动刷新
This commit is contained in:
shy3130
2026-09-08 21:45:38 +08:00
parent 8cb834d6a1
commit 85c903704a
5 changed files with 218 additions and 58 deletions
@@ -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)
}}
/>
</>
)
+35
View File
@@ -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))
}
+19 -1
View File
@@ -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] })
}
}}
/>