import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw, X } from 'lucide-react' import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api' import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental' import { DEFAULT_STRATEGY_NOTIFY_EVENTS } from '@/lib/strategyMonitorEvents' import { toast } from '@/components/Toast' import { useDataStatus, usePreferences, useCapabilities, useQuoteStatus } from '@/lib/useSharedQueries' import { useWatchlistBatchAdd } from '@/lib/useSharedMutations' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { DatePicker } from '@/components/DatePicker' import { StockPreviewDialog } from '@/components/StockPreviewDialog' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { useStrategyPool } from '@/lib/useStrategyPool' import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard' import { ScreenerTable } from '@/components/screener/ScreenerTable' import { ScreenerFilter as ScreenerFilterType, defaultFilter, filterActive, countActiveFilters, applyFilter, FilterPanel } from '@/components/screener/ScreenerFilter' import { StrategySettingsDialog } from '@/components/screener/StrategySettingsDialog' import { StrategyPoolDialog } from '@/components/screener/StrategyPoolDialog' import { StrategyBuilderDialog } from '@/components/screener/StrategyBuilderDialog' import { StrategyStoreDialog } from '@/components/screener/StrategyStoreDialog' import { CompositeStrategyDialog } from '@/components/screener/CompositeStrategyDialog' import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' import { useTableSort } from '@/components/stock-table/useTableSort' import { resolveCandleConfig } from '@/lib/list-columns' import { SCREENER_BUILTIN_COLUMNS, SCREENER_COLUMN_GROUPS, buildExtColumnsParam, loadScreenerColumnConfig, saveScreenerColumnConfig, type ColumnConfig, } from '@/lib/screener-columns' // 获取策略为占位功能, 暂时隐藏入口; 恢复时改回 true const SHOW_STRATEGY_STORE = false export function Screener() { const [assetType, setAssetType] = useState<'stock' | 'etf'>('stock') // 周期显示筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池和执行; // 执行按每个策略自己声明的 timeframes 路由 (日线走盘后缓存, 分钟走本地分钟K分区) const [tfFilter, setTfFilter] = useState<'all' | '1d' | '1m'>('all') const [activeStrategy, setActiveStrategy] = useState(null) const [result, setResult] = useState(null) const [asOf, setAsOf] = useState('') const [batchMsg, setBatchMsg] = useState('') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, []) const [settingsStrategyId, setSettingsStrategyId] = useState(null) const [showPoolDialog, setShowPoolDialog] = useState(false) const [showBuilder, setShowBuilder] = useState(false) 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 [cardSize, setCardSize] = useState(loadCardSize) // 日k蜡烛图显示开关(仅当 candle 列可见时才有意义;持久化) const [dailyKChartVisible, setDailyKChartVisible] = useState(() => storage.screenerCandle.get(true)) const toggleDailyKChart = useCallback(() => { setDailyKChartVisible(v => { const next = !v storage.screenerCandle.set(next) return next }) }, []) // 分时图显示开关(仅当 intraday 列可见时才有意义;持久化) const [intradayChartVisible, setIntradayChartVisible] = useState(() => storage.screenerIntraday.get(true)) const toggleIntradayChart = useCallback(() => { setIntradayChartVisible(v => { const next = !v storage.screenerIntraday.set(next) return next }) }, []) // 策略列标签全表展开/收起(命中多策略时行会很高;默认收起, 每行可单独展开;持久化) const [strategyTagsExpanded, setStrategyTagsExpanded] = useState(() => storage.screenerStrategyTags.get(false)) const toggleStrategyTags = useCallback(() => { setStrategyTagsExpanded(v => { const next = !v storage.screenerStrategyTags.set(next) return next }) }, []) // 截断提示可关闭 (仅本次会话, 不持久化) const [intradayCapDismissed, setIntradayCapDismissed] = useState(false) const [showAll, setShowAll] = useState(false) const [showFilter, setShowFilter] = useState(false) const [filter, setFilter] = useState(defaultFilter) const filterMap = useRef>(new Map()) const runAllDateRef = useRef(null) const qc = useQueryClient() // 结果列配置 — 默认内置列,异步合并后端/localStorage 偏好 const [columns, setColumns] = useState([...SCREENER_BUILTIN_COLUMNS]) const [customizerOpen, setCustomizerOpen] = useState(false) const columnsLoaded = useRef(false) useEffect(() => { if (columnsLoaded.current) return columnsLoaded.current = true loadScreenerColumnConfig().then(setColumns) }, []) const handleColumnsChange = useCallback((next: ColumnConfig[]) => { setColumns(next) saveScreenerColumnConfig(next) }, []) const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns]) // 各策略命中数 (进入页面自动跑) const [hitCounts, setHitCounts] = useState>({}) // 各策略失效数 (今日曾命中 - 当前命中) const [expiredCounts, setExpiredCounts] = useState>({}) // 各策略显示上限 (null = 全部) const [strategyLimits, setStrategyLimits] = useState>({}) // 筛选条件变化时同步到 map(供切换策略时读取最新值) useEffect(() => { if (activeStrategy) filterMap.current.set(activeStrategy, filter) }, [filter, activeStrategy]) // 切换策略时恢复该策略之前保存的筛选 const handleStrategySwitch = useCallback((strategyId: string) => { setFilter(filterMap.current.get(strategyId) ?? { ...defaultFilter }) }, []) // 对原始结果应用过滤 (memo: 否则每次渲染都对全部结果行过滤, // 且新数组身份会击穿下游 displayRows 的 memo) const filteredRows = useMemo( () => (result ? applyFilter(result.rows, filter) : []), [result, filter], ) const { data: prefs } = usePreferences() const screenerAutoRun = prefs?.screener_auto_run ?? true // 统一列表: 不按周期过滤, 日线+分钟策略合并返回, 分钟策略带 timeframes 标识 const strategies = useQuery({ queryKey: [...QK.screenerStrategies('all'), 'all'], 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({ queryKey: QK.screenerCachedSummary, queryFn: api.screenerCachedSummary, enabled: assetType === 'stock', }) const fullCachedQuery = useQuery({ queryKey: QK.screenerCached(asOf, extColumnsParam), queryFn: () => api.screenerCached(extColumnsParam || undefined), enabled: assetType === 'stock' && tfFilter !== '1m' && showAll, }) const singleCachedQuery = useQuery({ queryKey: QK.screenerCachedResult(activeStrategy ?? '', asOf, extColumnsParam), queryFn: () => api.screenerCachedResult(activeStrategy!, extColumnsParam || undefined), enabled: assetType === 'stock' && activeStrategyTimeframe === '1d' && !showAll && !!activeStrategy && summaryQuery.data?.results[activeStrategy]?.as_of === asOf, }) const dataStatus = useDataStatus({ staleTime: 0 }) // 默认日期 = enriched 最新日期(始终跟随最新) useEffect(() => { const latest = dataStatus.data?.enriched?.latest_date if (latest) setAsOf(latest) }, [dataStatus.data?.enriched?.latest_date]) const strategyPresets = useMemo( () => (strategies.data?.presets ?? []).filter(s => s.asset_types.includes(assetType)), [strategies.data, assetType], ) // 策略 ID → 名称映射 const strategyIdToName = useMemo(() => { const map: Record = {} for (const p of strategyPresets) { map[p.id] = p.name } return map }, [strategyPresets]) // 策略 ID → 完整对象映射(避免每张卡片 find 遍历) const strategyMap = useMemo(() => { const map = new Map() for (const p of strategyPresets) { map.set(p.id, p) } return map }, [strategyPresets]) const allStrategyIds = useMemo( () => new Set((strategies.data?.presets ?? []).map(s => s.id)), [strategies.data], ) const availableStrategyIds = useMemo( () => new Set(strategyPresets.map(s => s.id)), [strategyPresets], ) 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),避免"策略池"对话框持续显示失效项。 // 关键: 仅当本次拉取成功且返回非空列表时才 prune。 // 拉取中/失败/返回空(如引擎 reload 瞬时把某策略跳过)时一律不碰池, // 否则会把用户池里仍有效的 ID 永久清空并写入 localStorage,导致卡片全没。 // 日线/分钟池按周期隔离, 各自用自身周期的列表清理, 互不影响。 useEffect(() => { if (strategies.isError) return // 拉取失败: 不 prune if (!strategies.isSuccess) return // 加载中: 不 prune if (allStrategyIds.size === 0) return // 空列表: 不 prune prune(allStrategyIds) }, [allStrategyIds, prune, strategies.isError, strategies.isSuccess]) // 策略文件加载失败时提示用户(避免"策略静默消失"被误判为正常) const loadErrors = strategies.data?.load_errors ?? [] useEffect(() => { for (const e of loadErrors) { toast(`策略「${e.file}」加载失败:${e.error}`, 'error') } }, [loadErrors]) // 进入页面自动跑策略池中的策略,获取命中数 (仅日线策略; 分钟策略手动单跑) const runAll = useMutation({ mutationFn: ({ date, strategyIds }: { date?: string; strategyIds?: string[] } = {}) => api.screenerRunAll( date, strategyIds ?? dailyPoolIds, assetType, ), onSuccess: (data) => { if (data.as_of) setAsOf(data.as_of) const counts: Record = {} for (const [id, item] of Object.entries(data.results)) { counts[id] = item.total } setHitCounts(prev => ({ ...prev, ...counts })) qc.invalidateQueries({ queryKey: ['screener-cached'] }) }, }) const missingStrategyIds = useMemo( () => dailyPoolIds.filter(id => summaryQuery.data?.results[id]?.as_of !== asOf), [dailyPoolIds, summaryQuery.data, asOf], ) const cacheCoversPool = dailyPoolIds.length > 0 && missingStrategyIds.length === 0 // 防止 reload / auto-run / StrictMode 叠出并发 run_all(后端 Numba 会崩溃) // 用 ref 同步门闩,避免同一渲染周期内 isPending 尚未更新导致重复触发 const runAllPendingRef = useRef(false) const requestRunAll = useCallback(( vars: { date?: string; strategyIds?: string[] } = {}, options?: Parameters[1], ) => { if (runAllPendingRef.current || runAll.isPending) return runAllPendingRef.current = true runAll.mutate(vars, { ...options, onSettled: (...args) => { runAllPendingRef.current = false options?.onSettled?.(...args) }, }) }, [runAll]) // 摘要只同步当前日期的卡片数量,避免旧日期缓存短暂显示成当前结果。 useEffect(() => { if (!summaryQuery.data || !asOf) return const counts: Record = {} const expired: Record = {} for (const [id, r] of Object.entries(summaryQuery.data.results)) { if (r.as_of !== asOf) continue counts[id] = r.total const everCount = summaryQuery.data.today_ever_counts[id] ?? r.total const expiredCount = Math.max(everCount - r.total, 0) if (expiredCount > 0) expired[id] = expiredCount } setHitCounts(counts) setExpiredCounts(expired) }, [summaryQuery.data, asOf]) // 当前单策略缓存更新后同步明细;参数保存的强制重算结果仍由 run 直接覆盖。 useEffect(() => { const cached = singleCachedQuery.data?.result if (!cached || showAll || cached.strategy !== activeStrategy || cached.as_of !== asOf) return setResult(cached) if (activeStrategy) { setHitCounts(prev => ({ ...prev, [activeStrategy]: cached.total })) } }, [singleCachedQuery.data, showAll, activeStrategy, asOf]) const effectiveResults = useMemo(() => { if (fullCachedQuery.data?.as_of !== asOf) return null const entries = Object.entries(fullCachedQuery.data.results) .filter(([, item]) => item.as_of === asOf) return Object.fromEntries(entries) }, [fullCachedQuery.data, asOf]) // symbol → 所属策略列表。单策略接口同时返回轻量归属映射,保留策略列原有展示。 const symbolStrategyMap = useMemo(() => { const map = new Map() if (showAll) { for (const [sid, r] of Object.entries(effectiveResults ?? {})) { for (const row of r.rows) { const arr = map.get(row.symbol) if (arr) arr.push(sid) else map.set(row.symbol, [sid]) } } return map } for (const [symbol, ids] of Object.entries(singleCachedQuery.data?.strategy_ids_by_symbol ?? {})) { map.set(symbol, ids) } if (activeStrategy && result) { for (const row of result.rows) { if (!map.has(row.symbol)) map.set(row.symbol, [activeStrategy]) } } return map }, [showAll, effectiveResults, singleCachedQuery.data, activeStrategy, result]) // "全部" 模式: 合并所有策略的去重个股 const allRows = useMemo(() => { if (!effectiveResults) return [] const seen = new Set() const merged: any[] = [] for (const r of Object.values(effectiveResults)) { for (const row of r.rows) { if (!seen.has(row.symbol)) { seen.add(row.symbol) merged.push(row) } } } return merged }, [effectiveResults]) // 计算当前策略的失效行: 今日曾命中但当前已不命中。 const expiredRows = useMemo(() => { const everRows = singleCachedQuery.data?.today_ever_rows if (!everRows || !result || result.as_of !== asOf) return [] const currentSymbols = new Set(result.rows.map((row: any) => row.symbol)) return Object.entries(everRows) .filter(([symbol]) => !currentSymbols.has(symbol)) .map(([, row]) => ({ ...row, _expired: true })) }, [singleCachedQuery.data, result, asOf]) // 表头排序(受控):用户点击列则按该列;未点时下方按评分默认降序 const { sort, toggle, sortRows } = useTableSort() // 当前显示的行数据 (全部模式 或 单策略模式) + 失效行 const displayRows = useMemo(() => { let rows = showAll ? applyFilter(allRows, filter) : filteredRows // 排序:用户点了表头则按该列,否则默认评分降序 rows = sort ? sortRows(rows, columns) : [...rows].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity)) const limit = !showAll && activeStrategy ? strategyLimits[activeStrategy] ?? null : null const mainRows = limit != null ? rows.slice(0, limit) : rows // 追加当前策略的失效行 (灰色) if (!showAll && activeStrategy) { if (expiredRows.length > 0) { return [...mainRows, ...expiredRows] } } return mainRows }, [showAll, allRows, filteredRows, filter, activeStrategy, strategyLimits, expiredRows, sort, sortRows, columns]) // 日k列是否启用 → 决定是否加载批量 kline 数据 const candleColumn = useMemo(() => columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible), [columns], ) const candleColumnEnabled = !!candleColumn // 日k天数(来自列配置,已钳制边界) const candleDays = useMemo(() => resolveCandleConfig(candleColumn?.candleConfig).days, [candleColumn]) // 真正请求/渲染蜡烛图:列可见 且 眼睛开关开启 const dailyKVisible = candleColumnEnabled && dailyKChartVisible // 批量日k数据 (仅当蜡烛图可见时加载,省请求) const dailyKSymbols = useMemo( () => [...new Set(displayRows.map((r: any) => r.symbol as string))].sort(), [displayRows], ) const resultSymbolsKey = dailyKSymbols.join(',') const klineBatch = useQuery({ queryKey: QK.screenerKlineBatch(`${resultSymbolsKey}|${candleDays}`), queryFn: () => api.klineDailyBatch(dailyKSymbols, candleDays), enabled: dailyKVisible && dailyKSymbols.length > 0, staleTime: 5 * 60_000, placeholderData: previousData => previousData, }) const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {} // 分时列是否启用 → 决定是否加载批量分时数据 (需 kline.minute.batch 能力) const intradayColumn = useMemo(() => columns.find(c => c.source.type === 'builtin' && c.source.key === 'intraday' && c.visible), [columns], ) // 分时图依赖分钟K批量数据 (kline.minute.batch), 无数据时开了列也不拉 const caps = useCapabilities() const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch'] const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible // 分时数据加载策略 (与自选页一致, 简洁优先): // - 全量加载当前列表 symbol, 但按数据源 batch 上限截断, // 超出时只取前 batch 只并提示用户, 避免一次性发太多请求打爆 rpm 配额 // - 刷新: minute_intraday_refresh 偏好开启时按用户设定间隔轮询; 否则仅首次加载, // 用户可点表头刷新按钮手动更新 const minuteBatchCap = caps.data?.capabilities?.['kline.minute.batch']?.batch ?? 100 const quoteStatus = useQuoteStatus() const realtimeRunning = quoteStatus.data?.running ?? false const intradayRefreshEnabled = prefs?.minute_intraday_refresh ?? false const intradayRefreshInterval = prefs?.minute_intraday_refresh_interval ?? 6 const allIntradaySymbols = useMemo( () => displayRows.map((r: any) => r.symbol), [displayRows], ) const intradayTruncated = intradayVisible && allIntradaySymbols.length > minuteBatchCap // 截断到 batch 上限, 一次请求 = 一次数据源调用 const intradaySymbols = useMemo( () => intradayTruncated ? allIntradaySymbols.slice(0, minuteBatchCap) : allIntradaySymbols, [allIntradaySymbols, intradayTruncated, minuteBatchCap], ) const intradayRequestSymbols = useMemo( () => [...new Set(intradaySymbols)].sort(), [intradaySymbols], ) const intradaySymbolsKey = intradayRequestSymbols.join(',') const minuteBatch = useQuery({ queryKey: QK.minuteBatch(intradaySymbolsKey), // 增量轮询: 读缓存以最后一根为 since 只拉新增, 本地合并为完整序列 queryFn: () => fetchMinuteBatchIncremental(qc, QK.minuteBatch(intradaySymbolsKey), intradayRequestSymbols), enabled: intradayVisible && intradayRequestSymbols.length > 0, staleTime: 10_000, placeholderData: previousData => previousData, // 仅当开启分时刷新偏好 且 盘中实时行情运行时 才轮询 (省 rpm) refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false, }) const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {} // asOf 确定后 + 策略列表就绪 + 策略池非空 → 自动跑一次 (受系统设置开关控制) // 缓存命中时秒加载; 未命中时, 仅当 screener_auto_run 开启才自动触发 runAll useEffect(() => { // ETF 模式无股票盘后缓存/ runAll, 单策略走实时单跑, 不触发 runAll // 分钟筛选视图下不跑日线缓存 (切回 全部/日线 视图时本 effect 会重新评估) if (assetType !== 'stock' || tfFilter === '1m') return if (!asOf || strategyPresets.length === 0 || !summaryQuery.isSuccess || runAll.isPending || dailyPoolIds.length === 0) return const runKey = `${asOf}|${dailyPoolIds.join(',')}` if (runAllDateRef.current === runKey) return // 缓存已覆盖当前策略池 → 秒加载, 不触发 runAll if (cacheCoversPool) { runAllDateRef.current = runKey return } // 未覆盖: 受系统开关控制 if (!screenerAutoRun) return runAllDateRef.current = runKey requestRunAll({ date: asOf, strategyIds: missingStrategyIds }) }, [asOf, strategyPresets.length, summaryQuery.isSuccess, dailyPoolIds, cacheCoversPool, missingStrategyIds, screenerAutoRun, assetType, tfFilter, runAll.isPending, requestRunAll]) // 执行周期由策略自身声明决定: 日线走盘后缓存/单跑, 分钟走本地分钟K分区实时跑 const run = useMutation({ mutationFn: ({ id, date, timeframe: tf }: { id: string; date: string; timeframe: '1d' | '1m' }) => api.screenerRunPreset(id, undefined, date || undefined, extColumnsParam || undefined, assetType, tf), onSuccess: (data, vars) => { setResult(data) // 同步更新卡片上的命中数 setHitCounts(prev => ({ ...prev, [vars.id]: data.total })) // 单策略重跑后刷新摘要和当前按需明细,避免参数保存后回退到旧缓存。 qc.invalidateQueries({ queryKey: ['screener-cached'] }) }, }) const handleRun = (s: ScreenerStrategy) => { handleStrategySwitch(s.id) setActiveStrategy(s.id) setShowAll(false) if (result?.strategy !== s.id || result.as_of !== asOf) setResult(null) const tf = s.timeframes?.includes('1m') ? '1m' as const : '1d' as const // ETF 模式无股票盘后缓存、分钟策略走本地分钟分区 → 始终实时单跑。 // 传空日期让后端用自身的最新交易日 (ETF 与分钟分区跟股票 enriched 可能不同日)。 if (assetType !== 'stock' || tf === '1m') { run.mutate({ id: s.id, date: '', timeframe: tf }) return } // 摘要命中时由 singleCachedQuery 按需加载明细;缺失时才单独计算。 if (summaryQuery.data?.results[s.id]?.as_of === asOf || runAll.isPending) return run.mutate({ id: s.id, date: asOf, timeframe: tf }) } // 日期变化交给统一 effect 计算一次,避免这里与 effect 重复请求。 const handleDateChange = (newDate: string) => { setAsOf(newDate) runAllDateRef.current = null setResult(null) } const minDate = dataStatus.data?.enriched?.earliest_date ?? '' const maxDate = dataStatus.data?.enriched?.latest_date ?? '' const batchAdd = useWatchlistBatchAdd() // 自选股列表 (用于判断是否在自选中) const watchlist = useQuery({ queryKey: QK.watchlist, queryFn: api.watchlistList, }) const watchlistSet = useMemo(() => { const symbols = watchlist.data?.symbols ?? [] return new Set(symbols.map((s: any) => s.symbol)) }, [watchlist.data]) // 单只股票加入/移出自选 const toggleWatchlist = useMutation({ mutationFn: ({ symbol, action, groupId, }: { symbol: string action: 'add' | 'remove' groupId?: string | null }) => action === 'remove' ? api.watchlistRemove(symbol) : api.watchlistAdd(symbol, '', groupId), onSuccess: () => { qc.invalidateQueries({ queryKey: QK.watchlist }) qc.invalidateQueries({ queryKey: ['watchlist-enriched'] }) }, }) // 重新运行策略:重载策略文件 + 重跑全部策略,刷新符合条件的个股 const reloadStrategies = useMutation({ mutationFn: api.strategyReload, onSuccess: () => { qc.invalidateQueries({ queryKey: ['screener-strategies'] }) if (asOf) requestRunAll({ date: asOf }) }, }) // 策略监控: 查询规则, 建立 strategyId → ruleId 映射 (只看 type=strategy 且 enabled) const monitorRules = useQuery({ queryKey: QK.monitorRules, queryFn: api.monitorRulesList }) const strategyMonitorMap = useMemo(() => { const m = new Map() for (const r of monitorRules.data?.rules ?? []) { if (r.type === 'strategy' && r.enabled && r.strategy_id) { m.set(r.strategy_id, r.id) } } return m }, [monitorRules.data]) const toggleStrategyMonitor = (strategyId: string, strategyName: string) => { const existingRuleId = strategyMonitorMap.get(strategyId) if (existingRuleId) { // 已监控 → 删除规则 api.monitorRuleDelete(existingRuleId).then(() => qc.invalidateQueries({ queryKey: QK.monitorRules }), ) } else { // 未监控 → 直接创建 type=strategy 规则 api.monitorRuleSave({ id: genRuleId(), name: `策略监控 · ${strategyName}`, enabled: true, type: 'strategy', scope: 'all', symbols: [], sector: null, strategy_id: strategyId, direction: 'entry', notify_events: [...DEFAULT_STRATEGY_NOTIFY_EVENTS], conditions: [], logic: 'or', cooldown_seconds: 3600, severity: 'info', message: '', }).then(() => qc.invalidateQueries({ queryKey: QK.monitorRules })) } } const handleBatchAdd = (groupId: string | null) => { if (!displayRows.length) return const symbols = displayRows.map((r: any) => r.symbol) batchAdd.mutate({ symbols, groupId }, { onSuccess: (data) => { setBatchMsg(`已添加 ${data.added} 只到自选`) setTimeout(() => setBatchMsg(''), 3000) }, onError: () => { setBatchMsg('添加失败') setTimeout(() => setBatchMsg(''), 3000) }, }) } return ( <> {/* 资产类型切换: 股票 / ETF (分钟策略 asset_types 仅股票, ETF 列表自然不含) */}
{(['stock', 'etf'] as const).map(t => ( ))}
{/* 周期筛选: 全部 / 日线 / 分钟 — 只过滤卡片显示, 不影响池与执行路由 */}
{(['all', '1d', '1m'] as const).map(tf => ( ))}
{/* 重新运行策略:重载策略文件并重跑全部策略,更新命中个股 */} {asOf && ( )} {/* 全部切换 */} {/* 卡片尺寸切换 */}
{(['hidden', 'mini', 'normal', 'large'] as const).map(sz => ( ))}
{/* 策略池按钮 */} {/* 创建叠加策略 */} {/* 创建策略 */} {/* 获取策略(占位,敬请期待)— 暂时隐藏 */} {SHOW_STRATEGY_STORE && ( )} } />
{/* 策略卡片 */} {cardSize !== 'hidden' && (
{strategies.isLoading &&
加载中…
} {!strategies.isLoading && displayPool.length === 0 && (
{pool.length === 0 ? '策略池为空,点击右上角「策略池」按钮添加策略' : '当前周期筛选下无策略,切换周期筛选或编辑策略池'}
)}
{displayPool.map(id => { const s = strategyMap.get(id) if (!s) return null return ( handleRun(s)} disabled={run.isPending && activeStrategy === s.id} onSettings={() => setSettingsStrategyId(s.id)} monitored={strategyMonitorMap.has(s.id)} onToggleMonitor={() => toggleStrategyMonitor(s.id, s.name)} timeframeBadge={s.timeframes?.includes('1m') ? '分钟' : undefined} /> ) })}
)} {/* 结果 */}
{run.isError && (
{String((run.error as any).message)}
)} {(showAll ? allRows.length > 0 : !!result) && (

{!showAll && activeStrategy && ( {strategyIdToName[activeStrategy] ?? ''} )} {showAll ? '全部' : ''}命中 {displayRows.length} 只 {filterActive(filter) && displayRows.length !== (showAll ? allRows.length : result!.total) && ( / {showAll ? allRows.length : result!.total} )} · {displayPool.length} 策略 {!showAll && displayPool.length > 0 && ( <> · 共 {displayPool.reduce((sum, id) => sum + (hitCounts[id] ?? 0), 0)} 只 )} {runAll.isPending && ( 扫描中… )}

{(showAll ? allRows.length > 0 : !!result?.rows.length) && (
{filterActive(filter) && ( <> )}
)} {displayRows.length > 0 && ( {batchAdd.isPending ? '添加中…' : '批量加自选'} )} {batchMsg && ( {batchMsg} )} {!showAll && result && result.elapsed_ms > 0 && (
{result.elapsed_ms.toFixed(1)} ms
)} {/* 分时截断提示: 超数据源批量上限时在工具栏内联显示, 可关闭 */} {intradayTruncated && !intradayCapDismissed && ( 分时仅前 {minuteBatchCap}/{allIntradaySymbols.length} · 受数据源批量上限限制 )}
{/* 筛选面板: 只要原始结果有数据就显示 (哪怕筛完后为空, 用户才能改条件) */} {showFilter && (showAll ? allRows.length > 0 : !!result?.rows.length) && ( setShowFilter(false)} onReset={() => { setFilter(defaultFilter) if (activeStrategy) filterMap.current.delete(activeStrategy) }} /> )} {displayRows.length === 0 ? ( ) : ( <> { setPreviewSymbol(symbol); setPreviewName(name) }} onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })} onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })} watchlistPending={toggleWatchlist.isPending} klineData={klineData} dailyKChartVisible={dailyKChartVisible} onToggleDailyKChart={toggleDailyKChart} minuteData={minuteData} intradayChartVisible={intradayChartVisible} onToggleIntradayChart={toggleIntradayChart} intradayAutoRefresh={intradayRefreshEnabled && realtimeRunning} onRefreshIntraday={() => minuteBatch.refetch()} intradayRefreshing={minuteBatch.isFetching} strategyTagsExpanded={strategyTagsExpanded} onToggleStrategyTags={toggleStrategyTags} sort={sort} onSortToggle={toggle} /> )}
)} {!showAll && !result && !run.isPending && (
点击策略卡片查看选股结果 若提示 enriched 表无数据,请先运行盘后管道
)}
setCustomizerOpen(false)} title="自定义策略结果列" builtinSectionLabel="策略内置列" extColumnAlign="center" /> setSettingsStrategyId(null)} onSaved={(limit) => { if (settingsStrategyId) { setStrategyLimits(prev => ({ ...prev, [settingsStrategyId]: limit })) // 按策略自身周期重跑: 日线用当前 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 () => { if (!settingsStrategyId) return try { const [src, detail] = await Promise.all([ api.strategyGetSource(settingsStrategyId), api.strategyGet(settingsStrategyId), ]) storage.strategyModify.set({ name: detail.name ?? '', description: detail.description ?? '', direction: 'long', rules: storage.strategyRules.get({})[settingsStrategyId] ?? '', code: src.code, step: 2, strategyId: settingsStrategyId, source: src.source as any, }) setSettingsStrategyId(null) setBuilderMode('modify') setShowBuilder(true) } catch {} }} onDeleted={() => { if (settingsStrategyId) { removeFromPool(settingsStrategyId) const rules = storage.strategyRules.get({}) delete rules[settingsStrategyId]; storage.strategyRules.set(rules) setStrategyLimits(prev => { const next = {...prev}; delete next[settingsStrategyId]; return next }) qc.invalidateQueries({ queryKey: ['screener-strategies'] }) } }} /> {showPoolDialog && ( { reorderPool(newPool) }} onClose={() => setShowPoolDialog(false)} /> )} setShowBuilder(false)} mode={builderMode} existingStrategyIds={allStrategyIds} onSavedId={async id => { const data = await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) if (!data.presets.some(s => s.id === id)) { throw new Error(`策略 ${id} 已保存但未加载,请检查策略代码`) } addToPool(id) }} /> setShowComposite(false)} onSavedId={async id => { await qc.fetchQuery({ queryKey: QK.screenerStrategies('all'), queryFn: () => api.screenerStrategies(), staleTime: 0 }) addToPool(id) }} /> setShowStore(false)} /> ) }