From a342d94238749c8fbf8a3f4fd63ff7befb7c49a5 Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(screener):=20=E7=AD=96=E7=95=A5=E6=B1=A0?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=20=E2=80=94=20=E6=97=A5=E7=BA=BF/=E5=88=86?= =?UTF-8?q?=E9=92=9F=E5=90=88=E5=B9=B6=E5=8D=95=E6=B1=A0,=20=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E6=8C=89=E7=AD=96=E7=95=A5=E5=A3=B0=E6=98=8E=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原设计: 日线/分钟双池隔离 (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 视角)。 --- .../screener/StrategyPoolDialog.tsx | 15 +- frontend/src/lib/api.ts | 9 +- frontend/src/lib/storage.ts | 7 +- frontend/src/lib/useStrategyPool.ts | 74 ++++---- frontend/src/pages/Screener.tsx | 161 ++++++++++-------- 5 files changed, 148 insertions(+), 118 deletions(-) diff --git a/frontend/src/components/screener/StrategyPoolDialog.tsx b/frontend/src/components/screener/StrategyPoolDialog.tsx index 8f41e09..ffee88a 100644 --- a/frontend/src/components/screener/StrategyPoolDialog.tsx +++ b/frontend/src/components/screener/StrategyPoolDialog.tsx @@ -8,8 +8,6 @@ interface Props { pool: string[] onConfirm: (newPool: string[]) => void onClose: () => void - /** 列表周期: 1d 日线 / 1m 分钟, 与策略页当前周期一致 */ - timeframe?: '1d' | '1m' } const SOURCE_CLS: Record = { @@ -26,6 +24,8 @@ const SOURCE_LABEL: Record = { 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' 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, '') } -export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' }: Props) { +export function StrategyPoolDialog({ pool, onConfirm, onClose }: Props) { const backdrop = useDialogBackdrop(onClose) // 草稿状态: 打开时从 pool 复制, 操作只改草稿, 点确定才提交 const [draftPool, setDraftPool] = useState(() => [...pool]) @@ -59,7 +59,8 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' const loadStrategies = useCallback(async () => { setLoading(true) try { - const d = await api.strategyList(undefined, timeframe) + // 不按周期过滤: 日线+分钟策略合并展示, 分钟策略以徽章区分 + const d = await api.strategyList(undefined, 'all') setAllStrategies(d.strategies) } catch { setAllStrategies([]) @@ -238,6 +239,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' {SOURCE_LABEL[s.source] ?? '内置'} + {s.timeframes?.includes('1m') && ( + 分钟 + )} ))} @@ -282,6 +286,9 @@ export function StrategyPoolDialog({ pool, onConfirm, onClose, timeframe = '1d' {SOURCE_LABEL[src] ?? '内置'} + {s?.timeframes?.includes('1m') && ( + 分钟 + )} - ) - })} - - {/* 周期切换: 日线 (盘后缓存) / 分钟 (本地分钟K分区实时计算) */} -
- {(['1d', '1m'] as const).map(tf => ( + {(['stock', 'etf'] as const).map(t => ( + ))} +
+ {/* 周期筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池与执行路由 */} +
+ {(['all', '1d', '1m'] as const).map(tf => ( + ))}
@@ -754,13 +772,15 @@ export function Screener() { {cardSize !== 'hidden' && (
{strategies.isLoading &&
加载中…
} - {!strategies.isLoading && visiblePool.length === 0 && ( + {!strategies.isLoading && displayPool.length === 0 && (
- 策略池为空,点击右上角「策略池」按钮添加策略 + {pool.length === 0 + ? '策略池为空,点击右上角「策略池」按钮添加策略' + : '当前周期筛选下无策略,切换周期筛选或编辑策略池'}
)}
- {visiblePool.map(id => { + {displayPool.map(id => { const s = strategyMap.get(id) if (!s) return null return ( @@ -814,9 +834,9 @@ export function Screener() { / {showAll ? allRows.length : result!.total} )} - · {visiblePool.length} 策略 - {!showAll && visiblePool.length > 0 && ( - <> · 共 {visiblePool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} 只 + · {displayPool.length} 策略 + {!showAll && displayPool.length > 0 && ( + <> · 共 {displayPool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} 只 )} {runAll.isPending && ( @@ -1002,7 +1022,9 @@ export function Screener() { onSaved={(limit) => { if (settingsStrategyId) { 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 () => { @@ -1038,7 +1060,6 @@ export function Screener() { {showPoolDialog && ( { reorderPool(newPool) }} @@ -1055,7 +1076,7 @@ export function Screener() { if (!data.presets.some(s => s.id === id)) { throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`) } - addToPool(id, '1d') // 构建器创建的是日线策略, 即使在分钟页保存也入日线池 + addToPool(id) }} /> @@ -1064,7 +1085,7 @@ export function Screener() { onClose={() => setShowComposite(false)} onSavedId={async id => { await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) - addToPool(id, '1d') // 叠加策略为日线策略, 固定入日线池 + addToPool(id) }} />