feat(screener): 策略池统一 — 日线/分钟合并单池, 执行按策略声明周期路由

原设计: 日线/分钟双池隔离 (strategy-pool / strategy-pool-1m), 页面按
周期开关切换两套世界。拆池根因是列表按周期过滤 + 页面级周期切换: 分钟
视角下池内日线策略解析不到才显示「失效」, 属于修补表象而非消除根因。

- 池合并: 双 key 一次性迁移为单池 (日线在前、分钟在后、按 ID 去重,
  完成后删旧分钟 key 保证幂等, StrictMode/HMR 重放安全)
- 执行路由真相来源从 UI 开关改为策略自身 timeframes 声明: 日线走盘后
  缓存/runAll, 分钟走本地分钟K分区实时单跑 (卡片点击/参数保存重跑均
  按各自周期传参)
- 周期开关降级为三态显示筛选 (全部/日线/分钟, 默认全部), 只过滤卡片
  显示, 不影响池与执行; runAll 自动跑仅覆盖池内日线子集
- ETF 置灰移除: 分钟策略 asset_types 仅股票, ETF 列表自然不含
- 池对话框取全量列表, 待选/已选两侧显示「分钟」徽章; 失效判定回归
  全周期合并列表, 消解「切视角整池失效」问题类别
- 后端零改动: /api/strategies 的 timeframe 为空本就不过滤, 前端 'all'
  时省略参数; 其余依赖默认日线过滤的 strategyList 调用方行为不变

验证: pnpm build 通过; git diff --check 干净; 3 处 run.mutate 调用点
均带周期参数; 迁移幂等路径 (双池/仅分钟/空池) 走查通过。界面手工验证
待实机执行 (池迁移/分钟徽章/筛选空态/ETF 视角)。
This commit is contained in:
shy3130
2026-08-30 19:05:10 +08:00
parent b82c4eaedb
commit a342d94238
5 changed files with 148 additions and 118 deletions
@@ -8,8 +8,6 @@ interface Props {
pool: string[] pool: string[]
onConfirm: (newPool: string[]) => void onConfirm: (newPool: string[]) => void
onClose: () => void onClose: () => void
/** 列表周期: 1d 日线 / 1m 分钟, 与策略页当前周期一致 */
timeframe?: '1d' | '1m'
} }
const SOURCE_CLS: Record<string, string> = { const SOURCE_CLS: Record<string, string> = {
@@ -26,6 +24,8 @@ const SOURCE_LABEL: Record<string, string> = {
invalid: '失效', invalid: '失效',
} }
const TF_BADGE_CLS = 'text-[8px] px-1 py-px rounded border leading-tight shrink-0 border-purple-500/30 bg-purple-500/10 text-purple-400'
type SourceTab = 'all' | 'builtin' | 'custom' | 'ai' type SourceTab = 'all' | 'builtin' | 'custom' | 'ai'
const TABS: { id: SourceTab; label: string }[] = [ const TABS: { id: SourceTab; label: string }[] = [
@@ -44,7 +44,7 @@ function fileStem(name: string): string {
return name.replace(/\.py$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') return name.replace(/\.py$/i, '').replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '')
} }
export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' }: Props) { export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) {
const backdrop = useDialogBackdrop(onClose) const backdrop = useDialogBackdrop(onClose)
// 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交 // 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交
const [draftPool, setDraftPool] = useState<string[]>(() => [...pool]) const [draftPool, setDraftPool] = useState<string[]>(() => [...pool])
@@ -59,7 +59,8 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d'
const loadStrategies = useCallback(async () => { const loadStrategies = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const d = await api.strategyList(undefined, timeframe) // 不按周期过滤: 日线+分钟策略合并展示, 分钟策略以徽章区分
const d = await api.strategyList(undefined, 'all')
setAllStrategies(d.strategies) setAllStrategies(d.strategies)
} catch { } catch {
setAllStrategies([]) setAllStrategies([])
@@ -238,6 +239,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d'
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[s.source] ?? SOURCE_CLS.builtin}`}> <span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[s.source] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[s.source] ?? '内置'} {SOURCE_LABEL[s.source] ?? '内置'}
</span> </span>
{s.timeframes?.includes('1m') && (
<span className={TF_BADGE_CLS}></span>
)}
<Plus className="h-3.5 w-3.5 text-muted/40 group-hover:text-accent shrink-0" /> <Plus className="h-3.5 w-3.5 text-muted/40 group-hover:text-accent shrink-0" />
</button> </button>
))} ))}
@@ -282,6 +286,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d'
<span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[src] ?? SOURCE_CLS.builtin}`}> <span className={`text-[8px] px-1 py-px rounded border leading-tight shrink-0 ${SOURCE_CLS[src] ?? SOURCE_CLS.builtin}`}>
{SOURCE_LABEL[src] ?? '内置'} {SOURCE_LABEL[src] ?? '内置'}
</span> </span>
{s?.timeframes?.includes('1m') && (
<span className={TF_BADGE_CLS}></span>
)}
<button <button
onClick={(e) => { e.stopPropagation(); handleRemove(id) }} onClick={(e) => { e.stopPropagation(); handleRemove(id) }}
className="text-muted/40 hover:text-danger transition-colors cursor-pointer leading-none shrink-0" className="text-muted/40 hover:text-danger transition-colors cursor-pointer leading-none shrink-0"
+5 -4
View File
@@ -2116,9 +2116,10 @@ export const api = {
: '/api/watchlist/enriched', : '/api/watchlist/enriched',
), ),
screenerStrategies: async (assetType?: 'stock' | 'etf' | 'index', timeframe: '1d' | '1m' = '1d') => { // timeframe='all' 时不传参数 → 后端不过滤周期, 返回日线+分钟合并列表
screenerStrategies: async (assetType?: 'stock' | 'etf' | 'index', timeframe: '1d' | '1m' | 'all' = '1d') => {
const data = await request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>( const data = await request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>(
`/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}timeframe=${timeframe}`, `/api/strategies?${assetType ? `asset_type=${assetType}&` : ''}${timeframe !== 'all' ? `timeframe=${timeframe}` : ''}`,
) )
return { presets: data.strategies, load_errors: data.load_errors } return { presets: data.strategies, load_errors: data.load_errors }
}, },
@@ -2820,10 +2821,10 @@ export const api = {
}, },
// ===== Strategy Engine ===== // ===== Strategy Engine =====
strategyList: (assetType?: 'stock' | 'etf', timeframe = '1d') => { strategyList: (assetType?: 'stock' | 'etf', timeframe: '1d' | '1m' | 'all' = '1d') => {
const params = new URLSearchParams() const params = new URLSearchParams()
if (assetType) params.set('asset_type', assetType) if (assetType) params.set('asset_type', assetType)
if (timeframe) params.set('timeframe', timeframe) if (timeframe && timeframe !== 'all') params.set('timeframe', timeframe)
const qs = params.toString() const qs = params.toString()
return request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>( return request<{ strategies: StrategyDetail[]; load_errors?: StrategyLoadError[] }>(
`/api/strategies${qs ? `?${qs}` : ''}`, `/api/strategies${qs ? `?${qs}` : ''}`,
+5 -2
View File
@@ -17,6 +17,9 @@ function kv<T>(key: string) {
set(val: T) { set(val: T) {
try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ } try { localStorage.setItem(key, JSON.stringify(val)) } catch { /* ignore */ }
}, },
remove() {
try { localStorage.removeItem(key) } catch { /* ignore */ }
},
} }
} }
@@ -24,9 +27,9 @@ export const storage = {
/** 查询轮询 / SSE 配置 */ /** 查询轮询 / SSE 配置 */
queryConfig: kv<unknown>('tf-stocks-query-config'), queryConfig: kv<unknown>('tf-stocks-query-config'),
/** 策略池 (screener) — 日线池 */ /** 策略池 (screener) — 统一池 (日线+分钟共用, 执行按各自声明周期路由) */
strategyPool: kv<string[]>('strategy-pool'), strategyPool: kv<string[]>('strategy-pool'),
/** 策略池 (screener) — 分钟池 (与日线池按周期隔离) */ /** 旧分钟隔离池 — 仅作一次性迁移读取源, 迁移完成后移除该 key */
strategyPoolMinute: kv<string[]>('strategy-pool-1m'), strategyPoolMinute: kv<string[]>('strategy-pool-1m'),
/** 自选列表列配置 */ /** 自选列表列配置 */
+36 -38
View File
@@ -1,65 +1,63 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react'
import { storage } from '@/lib/storage' import { storage } from '@/lib/storage'
export type StrategyTimeframe = '1d' | '1m' // 旧版按周期隔离的双池 ('strategy-pool' 日线 + 'strategy-pool-1m' 分钟) 已合并为
// 统一池 ('strategy-pool'): 策略自带周期声明, 执行时按各自 timeframes 路由,
// 两周期各自独立持久化, 互不可见: 日线池只装日线策略, 分钟池只装分钟策略。 // 池不再按周期隔离。此处做一次性迁移: 日线在前、分钟在后、按 ID 去重,
// 切换周期时 pool 指向对应列表, 任何写操作都只会落到所属 key // 完成后移除旧分钟 key 保证幂等 (StrictMode 双调用 / HMR 重放均安全)
const POOL_STORES: Record<StrategyTimeframe, typeof storage.strategyPool> = { function loadUnifiedPool(): string[] {
'1d': storage.strategyPool, const minute = storage.strategyPoolMinute.get([])
'1m': storage.strategyPoolMinute, const daily = storage.strategyPool.get([])
if (minute.length === 0) {
storage.strategyPoolMinute.remove()
return daily
}
const merged = [...daily]
for (const id of minute) {
if (!merged.includes(id)) merged.push(id)
}
storage.strategyPool.set(merged)
storage.strategyPoolMinute.remove()
return merged
} }
/** /**
* 策略池 — 按周期 (日线/分钟) 隔离的两份池。 * 策略池 — 日线分钟策略共用的统一池。
* pool 为当前周期的列表; addToPool 可显式指定目标周期 * 卡片列表可按周期筛选显示, 但池本身只有一份;
* (如策略构建器创建的日线策略, 即使在分钟页保存也应入日线池)。 * addToPool 不再区分周期 (构建器/叠加策略创建的日线策略与分钟策略同池)。
*/ */
export function useStrategyPool(timeframe: StrategyTimeframe = '1d') { export function useStrategyPool() {
const [pools, setPools] = useState<Record<StrategyTimeframe, string[]>>(() => ({ const [pool, setPool] = useState<string[]>(loadUnifiedPool)
'1d': storage.strategyPool.get([]),
'1m': storage.strategyPoolMinute.get([]),
}))
const pool = pools[timeframe]
// 任一池变化即整体落库 (两份 key 幂等重写, 避免"只写当前池"漏掉非活跃池的更新)
useEffect(() => { useEffect(() => {
POOL_STORES['1d'].set(pools['1d']) storage.strategyPool.set(pool)
POOL_STORES['1m'].set(pools['1m']) }, [pool])
}, [pools])
const mutate = useCallback( const addToPool = useCallback((id: string) => {
(tf: StrategyTimeframe, updater: (prev: string[]) => string[]) => { setPool(prev => (prev.includes(id) ? prev : [...prev, id]))
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) => { const removeFromPool = useCallback((id: string) => {
mutate(timeframe, prev => prev.filter(x => x !== id)) setPool(prev => prev.filter(x => x !== id))
}, [mutate, timeframe]) }, [])
const reorderPool = useCallback((newOrder: string[]) => { const reorderPool = useCallback((newOrder: string[]) => {
mutate(timeframe, () => newOrder) setPool(newOrder)
}, [mutate, timeframe]) }, [])
// 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。 // 清除池中不存在于 validIds 的失效策略(如本地开发残留的自定义策略)。
// 调用方需以"当前周期自己的策略列表"传入, 各周期只清理各自的池 // 调用方传入"全周期合并的策略列表" ID, 池内日线/分钟策略一并校验
// 仅当确实有失效项时才更新,避免无谓重渲染。 // 仅当确实有失效项时才更新,避免无谓重渲染。
const prune = useCallback((validIds: Iterable<string>) => { const prune = useCallback((validIds: Iterable<string>) => {
const validSet = validIds instanceof Set ? validIds : new Set(validIds) const validSet = validIds instanceof Set ? validIds : new Set(validIds)
mutate(timeframe, prev => { setPool(prev => {
if (prev.length === 0) return prev if (prev.length === 0) return prev
const next = prev.filter(id => validSet.has(id)) const next = prev.filter(id => validSet.has(id))
return next.length === prev.length ? prev : next return next.length === prev.length ? prev : next
}) })
}, [mutate, timeframe]) }, [])
const isInPool = useCallback((id: string) => pool.includes(id), [pool]) const isInPool = useCallback((id: string) => pool.includes(id), [pool])
return { pool, pools, addToPool, removeFromPool, reorderPool, prune, isInPool } return { pool, addToPool, removeFromPool, reorderPool, prune, isInPool }
} }
+91 -70
View File
@@ -40,8 +40,9 @@ const SHOW_STRATEGY_STORE = false
export function Screener() { export function Screener() {
const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock') const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock')
// 周期: 日线 (盘后缓存 + runAll) / 分钟 (本地分钟K分区, 单策略实时跑) // 周期显示筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池和执行;
const [timeframe, setTimeframe] = useState<'1d' | '1m'>('1d') // 执行按每个策略自己声明的 timeframes 路由 (日线走盘后缓存, 分钟走本地分钟K分区)
const [tfFilter, setTfFilter] = useState<'all' | '1d' | '1m'>('all')
const [activeStrategy, setActiveStrategy] = useState<string | null>(null) const [activeStrategy, setActiveStrategy] = useState<string | null>(null)
const [result, setResult] = useState<ScreenerResult | null>(null) const [result, setResult] = useState<ScreenerResult | null>(null)
const [asOf, setAsOf] = useState<string>('') const [asOf, setAsOf] = useState<string>('')
@@ -55,7 +56,7 @@ export function Screener() {
const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create') const [builderMode, setBuilderMode] = useState<'create' | 'modify'>('create')
const [showStore, setShowStore] = useState(false) const [showStore, setShowStore] = useState(false)
const [showComposite, setShowComposite] = useState(false) const [showComposite, setShowComposite] = useState(false)
const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool(timeframe) const { pool, addToPool, removeFromPool, reorderPool, prune } = useStrategyPool()
const [cardSize, setCardSize] = useState<CardSize>(loadCardSize) const [cardSize, setCardSize] = useState<CardSize>(loadCardSize)
// 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化) // 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化)
const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => storage.screenerCandle.get(true)) const [dailyKChartVisible, setDailyKChartVisible] = useState<boolean>(() => storage.screenerCandle.get(true))
@@ -129,29 +130,39 @@ export function Screener() {
const { data: prefs } = usePreferences() const { data: prefs } = usePreferences()
const screenerAutoRun = prefs?.screener_auto_run ?? true const screenerAutoRun = prefs?.screener_auto_run ?? true
// 统一列表: 不按周期过滤, 日线+分钟策略合并返回, 分钟策略带 timeframes 标识
const strategies = useQuery({ const strategies = useQuery({
queryKey: [...QK.screenerStrategies('all'), timeframe], queryKey: [...QK.screenerStrategies('all'), 'all'],
queryFn: () => api.screenerStrategies(undefined, timeframe), queryFn: () => api.screenerStrategies(undefined, 'all'),
}) })
// 激活策略自身的执行周期 (决定走缓存还是分钟实时跑)。
// 在 queries 之前独立计算, 避免依赖下方 strategyMap 的定义顺序。
const activeStrategyTimeframe = useMemo(() => {
if (!activeStrategy) return '1d' as const
const meta = (strategies.data?.presets ?? []).find(s => s.id === activeStrategy)
return meta?.timeframes?.includes('1m') ? ('1m' as const) : ('1d' as const)
}, [strategies.data, activeStrategy])
// 卡片首屏只读取轻量摘要;明细在点击策略或“全部”时按需加载。 // 卡片首屏只读取轻量摘要;明细在点击策略或“全部”时按需加载。
// 摘要只覆盖日线缓存; 分钟策略命中数来自手动单跑。
const summaryQuery = useQuery({ const summaryQuery = useQuery({
queryKey: QK.screenerCachedSummary, queryKey: QK.screenerCachedSummary,
queryFn: api.screenerCachedSummary, queryFn: api.screenerCachedSummary,
enabled: assetType === 'stock' && timeframe === '1d', enabled: assetType === 'stock',
}) })
const fullCachedQuery = useQuery({ const fullCachedQuery = useQuery({
queryKey: QK.screenerCached(asOf, extColumnsParam), queryKey: QK.screenerCached(asOf, extColumnsParam),
queryFn: () => api.screenerCached(extColumnsParam || undefined), queryFn: () => api.screenerCached(extColumnsParam || undefined),
enabled: assetType === 'stock' && timeframe === '1d' && showAll, enabled: assetType === 'stock' && tfFilter !== '1m' && showAll,
}) })
const singleCachedQuery = useQuery({ const singleCachedQuery = useQuery({
queryKey: QK.screenerCachedResult(activeStrategy ?? '', asOf, extColumnsParam), queryKey: QK.screenerCachedResult(activeStrategy ?? '', asOf, extColumnsParam),
queryFn: () => api.screenerCachedResult(activeStrategy!, extColumnsParam || undefined), queryFn: () => api.screenerCachedResult(activeStrategy!, extColumnsParam || undefined),
enabled: assetType === 'stock' enabled: assetType === 'stock'
&& timeframe === '1d' && activeStrategyTimeframe === '1d'
&& !showAll && !showAll
&& !!activeStrategy && !!activeStrategy
&& summaryQuery.data?.results[activeStrategy]?.as_of === asOf, && summaryQuery.data?.results[activeStrategy]?.as_of === asOf,
@@ -198,6 +209,19 @@ export function Screener() {
) )
const visiblePool = useMemo(() => pool.filter(id => availableStrategyIds.has(id)), [pool, availableStrategyIds]) const visiblePool = useMemo(() => pool.filter(id => availableStrategyIds.has(id)), [pool, availableStrategyIds])
// 卡片显示: 按周期筛选 (all=全部, 1d=仅日线, 1m=仅分钟); 未声明 timeframes 视为日线
const displayPool = useMemo(() => visiblePool.filter(id => {
if (tfFilter === 'all') return true
const isMinute = strategyMap.get(id)?.timeframes?.includes('1m') ?? false
return tfFilter === '1m' ? isMinute : !isMinute
}), [visiblePool, strategyMap, tfFilter])
// runAll/盘后缓存只覆盖日线策略; 池中分钟策略由手动单跑实时计算
const dailyPoolIds = useMemo(
() => visiblePool.filter(id => !(strategyMap.get(id)?.timeframes?.includes('1m') ?? false)),
[visiblePool, strategyMap],
)
// 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、 // 策略列表加载后,自动清除池中失效的自定义策略(如本地开发残留的、
// 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。 // 当前后端已不存在的策略 ID),避免"策略池"对话框持续显示失效项。
// 关键: 仅当本次拉取成功且返回非空列表时才 prune。 // 关键: 仅当本次拉取成功且返回非空列表时才 prune。
@@ -219,12 +243,12 @@ export function Screener() {
} }
}, [loadErrors]) }, [loadErrors])
// 进入页面自动跑策略池中的策略,获取命中数 // 进入页面自动跑策略池中的策略,获取命中数 (仅日线策略; 分钟策略手动单跑)
const runAll = useMutation({ const runAll = useMutation({
mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) => mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) =>
api.screenerRunAll( api.screenerRunAll(
date, date,
strategyIds ?? visiblePool, strategyIds ?? dailyPoolIds,
assetType, assetType,
), ),
onSuccess: (data) => { onSuccess: (data) => {
@@ -239,10 +263,10 @@ export function Screener() {
}) })
const missingStrategyIds = useMemo( const missingStrategyIds = useMemo(
() => visiblePool.filter(id => summaryQuery.data?.results[id]?.as_of !== asOf), () => dailyPoolIds.filter(id => summaryQuery.data?.results[id]?.as_of !== asOf),
[visiblePool, summaryQuery.data, asOf], [dailyPoolIds, summaryQuery.data, asOf],
) )
const cacheCoversPool = visiblePool.length > 0 && missingStrategyIds.length === 0 const cacheCoversPool = dailyPoolIds.length > 0 && missingStrategyIds.length === 0
// 防止 reload / auto-run / StrictMode 叠出并发 run_all(后端 Numba 会崩溃) // 防止 reload / auto-run / StrictMode 叠出并发 run_all(后端 Numba 会崩溃)
// 用 ref 同步门闩,避免同一渲染周期内 isPending 尚未更新导致重复触发 // 用 ref 同步门闩,避免同一渲染周期内 isPending 尚未更新导致重复触发
@@ -449,10 +473,10 @@ export function Screener() {
// 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll // 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll
useEffect(() => { useEffect(() => {
// ETF 模式无股票盘后缓存/ runAll, 单策略走实时单跑, 不触发 runAll // ETF 模式无股票盘后缓存/ runAll, 单策略走实时单跑, 不触发 runAll
// 分钟模式走本地分钟K分区, 同样不触发 runAll (盘后缓存是日线语义) // 分钟筛选视图下不跑日线缓存 (切回 全部/日线 视图时本 effect 会重新评估)
if (assetType !== 'stock' || timeframe !== '1d') return if (assetType !== 'stock' || tfFilter === '1m') return
if (!asOf || strategyPresets.length === 0 || !summaryQuery.isSuccess || runAll.isPending || visiblePool.length === 0) return if (!asOf || strategyPresets.length === 0 || !summaryQuery.isSuccess || runAll.isPending || dailyPoolIds.length === 0) return
const runKey = `${asOf}|${visiblePool.join(',')}` const runKey = `${asOf}|${dailyPoolIds.join(',')}`
if (runAllDateRef.current === runKey) return if (runAllDateRef.current === runKey) return
// 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll // 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll
if (cacheCoversPool) { if (cacheCoversPool) {
@@ -463,11 +487,12 @@ export function Screener() {
if (!screenerAutoRun) return if (!screenerAutoRun) return
runAllDateRef.current = runKey runAllDateRef.current = runKey
requestRunAll({ date: asOf, strategyIds: missingStrategyIds }) requestRunAll({ date: asOf, strategyIds: missingStrategyIds })
}, [asOf, strategyPresets.length, summaryQuery.isSuccess, visiblePool, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, timeframe, runAll.isPending, requestRunAll]) }, [asOf, strategyPresets.length, summaryQuery.isSuccess, dailyPoolIds, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, tfFilter, runAll.isPending, requestRunAll])
// 执行周期由策略自身声明决定: 日线走盘后缓存/单跑, 分钟走本地分钟K分区实时跑
const run = useMutation({ const run = useMutation({
mutationFn: ({ id, date }: { id: string; date: string }) => mutationFn: ({ id, date, timeframe: tf }: { id: string; date: string; timeframe: '1d' | '1m' }) =>
api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined, assetType, timeframe), api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined, assetType, tf),
onSuccess: (data, vars) => { onSuccess: (data, vars) => {
setResult(data) setResult(data)
// 同步更新卡片上的命中数 // 同步更新卡片上的命中数
@@ -482,15 +507,16 @@ export function Screener() {
setActiveStrategy(s.id) setActiveStrategy(s.id)
setShowAll(false) setShowAll(false)
if (result?.strategy !== s.id || result.as_of !== asOf) setResult(null) if (result?.strategy !== s.id || result.as_of !== asOf) setResult(null)
// ETF 模式: 无股票盘后缓存, 始终实时单跑。 const tf = s.timeframes?.includes('1m') ? '1m' as const : '1d' as const
// 传空日期让后端用 ETF 自己的最新交易日 (asOf 跟随的是股票 enriched, 两者可能不同日) // ETF 模式无股票盘后缓存、分钟策略走本地分钟分区 → 始终实时单跑
if (assetType !== 'stock' || timeframe !== '1d') { // 传空日期让后端用自身的最新交易日 (ETF 与分钟分区跟股票 enriched 可能不同日)。
run.mutate({ id: s.id, date: '' }) if (assetType !== 'stock' || tf === '1m') {
run.mutate({ id: s.id, date: '', timeframe: tf })
return return
} }
// 摘要命中时由 singleCachedQuery 按需加载明细;缺失时才单独计算。 // 摘要命中时由 singleCachedQuery 按需加载明细;缺失时才单独计算。
if (summaryQuery.data?.results[s.id]?.as_of === asOf || runAll.isPending) return if (summaryQuery.data?.results[s.id]?.as_of === asOf || runAll.isPending) return
run.mutate({ id: s.id, date: asOf }) run.mutate({ id: s.id, date: asOf, timeframe: tf })
} }
// 日期变化交给统一 effect 计算一次,避免这里与 effect 重复请求。 // 日期变化交给统一 effect 计算一次,避免这里与 effect 重复请求。
@@ -607,47 +633,39 @@ export function Screener() {
subtitle="基于本地 enriched 表 · 毫秒级 SQL" subtitle="基于本地 enriched 表 · 毫秒级 SQL"
right={ right={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 资产类型切换: 股票 / ETF (分钟策略仅支持股票, 1m 模式下 ETF 置灰) */} {/* 资产类型切换: 股票 / ETF (分钟策略 asset_types 仅股票, ETF 列表自然不含) */}
<div className="flex items-center h-7 rounded-btn border border-border overflow-hidden"> <div className="flex items-center h-7 rounded-btn border border-border overflow-hidden">
{(['stock', 'etf'] as const).map(t => { {(['stock', 'etf'] as const).map(t => (
const disabled = t === 'etf' && timeframe === '1m'
return (
<button
key={t}
disabled={disabled}
title={disabled ? '分钟策略仅支持股票' : undefined}
onClick={() => { setAssetType(t); setActiveStrategy(null); setResult(null); setShowAll(false) }}
className={`h-full px-2.5 text-xs font-medium transition-colors
${disabled
? 'text-muted/40 cursor-not-allowed'
: 'cursor-pointer ' + (assetType === t
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated')
}`}
>
{t === 'stock' ? '股票' : 'ETF'}
</button>
)
})}
</div>
{/* 周期切换: 日线 (盘后缓存) / 分钟 (本地分钟K分区实时计算) */}
<div className="flex items-center h-7 rounded-btn border border-border overflow-hidden">
{(['1d', '1m'] as const).map(tf => (
<button <button
key={tf} key={t}
onClick={() => { onClick={() => { setAssetType(t); setActiveStrategy(null); setResult(null); setShowAll(false) }}
if (timeframe === tf) return className={`h-full px-2.5 text-xs font-medium transition-colors
setTimeframe(tf) cursor-pointer ${assetType === t
setActiveStrategy(null); setResult(null); setShowAll(false)
if (tf === '1m') setAssetType('stock')
}}
className={`h-full px-2.5 text-xs font-medium transition-colors cursor-pointer
${timeframe === tf
? 'bg-accent/10 text-accent' ? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated' : 'text-muted hover:text-secondary hover:bg-elevated'
}`} }`}
> >
{tf === '1d' ? '日线' : '分钟'} {t === 'stock' ? '股票' : 'ETF'}
</button>
))}
</div>
{/* 周期筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池与执行路由 */}
<div className="flex items-center h-7 rounded-btn border border-border overflow-hidden">
{(['all', '1d', '1m'] as const).map(tf => (
<button
key={tf}
onClick={() => {
if (tfFilter === tf) return
setTfFilter(tf)
setActiveStrategy(null); setResult(null); setShowAll(false)
}}
className={`h-full px-2.5 text-xs font-medium transition-colors cursor-pointer
${tfFilter === tf
? 'bg-accent/10 text-accent'
: 'text-muted hover:text-secondary hover:bg-elevated'
}`}
>
{tf === 'all' ? '全部' : tf === '1d' ? '日线' : '分钟'}
</button> </button>
))} ))}
</div> </div>
@@ -754,13 +772,15 @@ export function Screener() {
{cardSize !== 'hidden' && ( {cardSize !== 'hidden' && (
<section> <section>
{strategies.isLoading && <div className="text-sm text-muted"></div>} {strategies.isLoading && <div className="text-sm text-muted"></div>}
{!strategies.isLoading && visiblePool.length === 0 && ( {!strategies.isLoading && displayPool.length === 0 && (
<div className="text-sm text-muted py-4 text-center border border-dashed border-border rounded-btn"> <div className="text-sm text-muted py-4 text-center border border-dashed border-border rounded-btn">
{pool.length === 0
? '策略池为空,点击右上角「策略池」按钮添加策略'
: '当前周期筛选下无策略,切换周期筛选或编辑策略池'}
</div> </div>
)} )}
<div className={cardWrapCls(cardSize)}> <div className={cardWrapCls(cardSize)}>
{visiblePool.map(id => { {displayPool.map(id => {
const s = strategyMap.get(id) const s = strategyMap.get(id)
if (!s) return null if (!s) return null
return ( return (
@@ -814,9 +834,9 @@ export function Screener() {
<span className="text-muted text-xs">/ {showAll ? allRows.length : result!.total}</span> <span className="text-muted text-xs">/ {showAll ? allRows.length : result!.total}</span>
)} )}
<span className="text-[11px] text-muted font-normal"> <span className="text-[11px] text-muted font-normal">
· {visiblePool.length} · {displayPool.length}
{!showAll && visiblePool.length > 0 && ( {!showAll && displayPool.length > 0 && (
<> · {visiblePool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} </> <> · {displayPool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} </>
)} )}
</span> </span>
{runAll.isPending && ( {runAll.isPending && (
@@ -1002,7 +1022,9 @@ export function Screener() {
onSaved={(limit) => { onSaved={(limit) => {
if (settingsStrategyId) { if (settingsStrategyId) {
setStrategyLimits(prev => ({ ...prev, [settingsStrategyId]: limit })) setStrategyLimits(prev => ({ ...prev, [settingsStrategyId]: limit }))
run.mutate({ id: settingsStrategyId, date: asOf }) // 按策略自身周期重跑: 日线用当前 asOf, 分钟实时单跑交后端取最新分区
const tf = strategyMap.get(settingsStrategyId)?.timeframes?.includes('1m') ? '1m' as const : '1d' as const
run.mutate({ id: settingsStrategyId, date: tf === '1m' ? '' : asOf, timeframe: tf })
} }
}} }}
onAiModify={async () => { onAiModify={async () => {
@@ -1038,7 +1060,6 @@ export function Screener() {
{showPoolDialog && ( {showPoolDialog && (
<StrategyPoolDialog <StrategyPoolDialog
pool={pool} pool={pool}
timeframe={timeframe}
onConfirm={(newPool) => { onConfirm={(newPool) => {
reorderPool(newPool) reorderPool(newPool)
}} }}
@@ -1055,7 +1076,7 @@ export function Screener() {
if (!data.presets.some(s => s.id === id)) { if (!data.presets.some(s => s.id === id)) {
throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`) throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`)
} }
addToPool(id, '1d') // 构建器创建的是日线策略, 即使在分钟页保存也入日线池 addToPool(id)
}} }}
/> />
@@ -1064,7 +1085,7 @@ export function Screener() {
onClose={() => setShowComposite(false)} onClose={() => setShowComposite(false)}
onSavedId={async id => { onSavedId={async id => {
await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 })
addToPool(id, '1d') // 叠加策略为日线策略, 固定入日线池 addToPool(id)
}} }}
/> />