fix(screener): 策略池按日线/分钟周期隔离, 修复分钟视角下整池显示失效

原设计: 日线/分钟共用一份策略池。切到分钟模式后, 池中 15 个日线策略
在分钟策略列表里解析不到, 策略池对话框右侧全部标成「失效」; 此时确认
还会把混合名单回写全局池, 日线池同样被污染。

- 池按周期隔离: 'strategy-pool' (日线, 沿用原 key 存量无损) +
  'strategy-pool-1m' (分钟, 新 key); useStrategyPool(timeframe) 返回
  当前周期的池, 任一池变更幂等落库双 key, 切换周期不会交叉写
- 「失效」徽章回归本义: 仅当策略真被删除 (不在本周期列表) 时出现
- prune 解除仅日线限制: 各周期用自身策略列表清理各自的池, 互不误删
- 策略构建器/叠加策略创建的是日线策略, 固定入日线池 (即使分钟页保存)

验证: 分钟对话框无失效列表、可正常添加分钟红7; 日线 15/31 ↔ 分钟 1/1
往返切换稳定; 刷新后双池持久。

注: 开发中 vite HMR 热替换瞬间曾把本地日线池写空 (旧代码对话框与新
模块混布的开发态一次性产物, 生产构建无 HMR 不受影响); 已从用户截图
复原原 15 策略及顺序 (boll_breakout → five_consecutive_yang)。
This commit is contained in:
shy3130
2026-08-30 19:05:08 +08:00
parent 1746a0e305
commit bfa87d1f0b
3 changed files with 51 additions and 21 deletions
+3 -1
View File
@@ -24,8 +24,10 @@ export const storage = {
/** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'),
/** 策略池 (screener) */
/** 策略池 (screener) — 日线池 */
strategyPool: kv<string[]>('strategy-pool'),
/** 策略池 (screener) — 分钟池 (与日线池按周期隔离) */
strategyPoolMinute: kv<string[]>('strategy-pool-1m'),
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
+43 -14
View File
@@ -1,36 +1,65 @@
import { useState, useCallback, useEffect } from 'react'
import { storage } from '@/lib/storage'
export function useStrategyPool() {
const [pool, setPool] = useState<string[]>(() => storage.strategyPool.get([]))
export type StrategyTimeframe = '1d' | '1m'
// 同步写入 localStorage
useEffect(() => { storage.strategyPool.set(pool) }, [pool])
// 两周期各自独立持久化, 互不可见: 日线池只装日线策略, 分钟池只装分钟策略。
// 切换周期时 pool 指向对应列表, 任何写操作都只会落到所属 key。
const POOL_STORES: Record<StrategyTimeframe, typeof storage.strategyPool> = {
'1d': storage.strategyPool,
'1m': storage.strategyPoolMinute,
}
const addToPool = useCallback((id: string) => {
setPool(prev => prev.includes(id) ? prev : [...prev, id])
}, [])
/**
* 策略池 — 按周期 (日线/分钟) 隔离的两份池。
* pool 为当前周期的列表; addToPool 可显式指定目标周期
* (如策略构建器创建的是日线策略, 即使在分钟页保存也应入日线池)。
*/
export function useStrategyPool(timeframe: StrategyTimeframe = '1d') {
const [pools, setPools] = useState<Record<StrategyTimeframe, string[]>>(() => ({
'1d': storage.strategyPool.get([]),
'1m': storage.strategyPoolMinute.get([]),
}))
const pool = pools[timeframe]
// 任一池变化即整体落库 (两份 key 幂等重写, 避免"只写当前池"漏掉非活跃池的更新)
useEffect(() => {
POOL_STORES['1d'].set(pools['1d'])
POOL_STORES['1m'].set(pools['1m'])
}, [pools])
const mutate = useCallback(
(tf: StrategyTimeframe, updater: (prev: string[]) => string[]) => {
setPools(prev => ({ ...prev, [tf]: updater(prev[tf]) }))
},
[],
)
const addToPool = useCallback((id: string, tf: StrategyTimeframe = timeframe) => {
mutate(tf, prev => (prev.includes(id) ? prev : [...prev, id]))
}, [mutate, timeframe])
const removeFromPool = useCallback((id: string) => {
setPool(prev => prev.filter(x => x !== id))
}, [])
mutate(timeframe, prev => prev.filter(x => x !== id))
}, [mutate, timeframe])
const reorderPool = useCallback((newOrder: string[]) => {
setPool(newOrder)
}, [])
mutate(timeframe, () => newOrder)
}, [mutate, timeframe])
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
// 调用方需以"当前周期自己的策略列表"传入, 各周期只清理各自的池。
// 仅当确实有失效项时才更新,避免无谓重渲染。
const prune = useCallback((validIds: Iterable<string>) => {
const validSet = validIds instanceof Set ? validIds : new Set(validIds)
setPool(prev => {
mutate(timeframe, prev => {
if (prev.length === 0) return prev
const next = prev.filter(id => validSet.has(id))
return next.length === prev.length ? prev : next
})
}, [])
}, [mutate, timeframe])
const isInPool = useCallback((id: string) => pool.includes(id), [pool])
return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
return { pool, pools, addToPool, removeFromPool, reorderPool, prune, isInPool }
}
+5 -6
View File
@@ -55,7 +55,7 @@ export function Screener() {
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
const [showStore, setShowStore] = useState(false)
const [showComposite, setShowComposite] = useState(false)
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool(timeframe)
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => storage.screenerCandle.get(true))
@@ -203,14 +203,13 @@ export function Screener() {
// 关键: 仅当本次拉取成功且返回非空列表时才 prune。
// 拉取中/失败/返回空(如引擎 reload 瞬时把某策略跳过)时一律不碰池,
// 否则会把用户池里仍有效的 ID 永久清空并写入 localStorage,导致卡片全没。
// 日线/分钟池按周期隔离, 各自用自身周期的列表清理, 互不影响。
useEffect(() => {
if (strategies.isError) return // 拉取失败: 不 prune
if (!strategies.isSuccess) return // 加载中: 不 prune
if (allStrategyIds.size === 0) return // 空列表: 不 prune
// 分钟模式的列表只含分钟策略, prune 会误删池中的日线策略 → 仅日线模式清理
if (timeframe !== '1d') return
prune(allStrategyIds)
}, [allStrategyIds, prune, strategies.isError, strategies.isSuccess, timeframe])
}, [allStrategyIds, prune, strategies.isError, strategies.isSuccess])
// 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常)
const loadErrors = strategies.data?.load_errors ?? []
@@ -1056,7 +1055,7 @@ export function Screener() {
if (!data.presets.some(s => s.id === id)) {
throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)
}
addToPool(id)
addToPool(id, '1d') // 构建器创建的是日线策略, 即使在分钟页保存也入日线池
}}
/>
@@ -1065,7 +1064,7 @@ export function Screener() {
onClose={() => setShowComposite(false)}
onSavedId={async id => {
await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
addToPool(id)
addToPool(id, '1d') // 叠加策略为日线策略, 固定入日线池
}}
/>