包裹
+ return
{tagEls}
+}
+
+/** 渲染扩展数据列的
*/
+function renderExtCell(
+ r: any,
+ col: ColumnConfig,
+ expandedCells: Set,
+ onToggleExpand: (key: string) => void,
+): React.ReactNode {
+ if (col.source.type !== 'ext') return null
+ const { configId, fieldName } = col.source
+ const val = r[`${configId}__${fieldName}`]
+ const cellKey = `${r.symbol}::${col.id}`
+ const expanded = expandedCells.has(cellKey)
+
+ const style: React.CSSProperties = {}
+ if (col.extDisplay?.maxWidth) {
+ style.maxWidth = col.extDisplay.maxWidth
+ }
+
+ // 根据值类型决定 td class
+ const tdClass = val == null || Number.isNaN(val)
+ ? 'px-2 py-1.5 text-right num tabular-nums text-muted'
+ : typeof val === 'number'
+ ? 'px-2 py-1.5 text-right num tabular-nums'
+ : typeof val === 'boolean'
+ ? 'px-2 py-1.5 text-right'
+ : 'px-2 py-1.5'
+
+ return (
+
+ {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey))}
+
+ )
+}
+
+// ===== 搜索框组件(紧凑内联式)=====
+
+function StockSearchBox({
+ onPreview,
+ existingSymbols,
+ onAdd,
+}: {
+ onPreview: (symbol: string, name: string) => void
+ existingSymbols: string[]
+ onAdd: (symbol: string) => void
+}) {
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const containerRef = useRef(null)
+ const inputRef = useRef(null)
+ const [activeIdx, setActiveIdx] = useState(-1)
+
+ const search = useQuery({
+ queryKey: QK.instrumentSearch(query),
+ queryFn: () => api.instrumentSearch(query),
+ enabled: query.trim().length > 0,
+ staleTime: 30_000,
+ })
+
+ const results = search.data?.results ?? []
+
+ useEffect(() => {
+ function handleClick(e: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClick)
+ return () => document.removeEventListener('mousedown', handleClick)
+ }, [])
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === 'Escape') { setOpen(false); return }
+ if (!open || results.length === 0) return
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ setActiveIdx(i => Math.min(i + 1, results.length - 1))
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ setActiveIdx(i => Math.max(i - 1, -1))
+ } else if (e.key === 'Enter') {
+ e.preventDefault()
+ if (activeIdx >= 0) handleSelect(results[activeIdx])
+ else if (results.length > 0) handleSelect(results[0])
+ }
+ }
+
+ function handleSelect(r: { symbol: string; name: string }) {
+ onPreview(r.symbol, r.name)
+ setQuery('')
+ setOpen(false)
+ setActiveIdx(-1)
+ }
+
+ return (
+
+
+
+ { setQuery(e.target.value); setOpen(true); setActiveIdx(-1) }}
+ onFocus={() => { if (query.trim()) setOpen(true) }}
+ onKeyDown={handleKeyDown}
+ className="w-44 h-8 pl-8 pr-2.5 rounded-btn bg-elevated border border-border text-xs text-foreground placeholder:text-muted focus:outline-none focus:border-accent/50 focus:w-56 transition-all duration-200"
+ />
+
+
+
+ {open && results.length > 0 && (
+
+ {results.map((r, i) => {
+ const inWatchlist = existingSymbols.includes(r.symbol)
+ return (
+
+
handleSelect(r)}
+ className="flex items-center gap-2.5 flex-1 min-w-0 text-left"
+ >
+ {r.symbol}
+ {r.name}
+
+
{ e.stopPropagation(); onAdd(r.symbol) }}
+ disabled={inWatchlist}
+ className={`shrink-0 p-1 rounded transition-colors ${
+ inWatchlist
+ ? 'text-accent bg-accent/10 cursor-default'
+ : 'text-muted hover:text-accent hover:bg-accent/10'
+ }`}
+ title={inWatchlist ? '已加自选' : '加入自选'}
+ >
+ {inWatchlist ? : }
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+// ===== 卡片组件 =====
+
+function StockCard({
+ r,
+ candleRows,
+ showCandle,
+ onPreview,
+ onConfirmRemove,
+ onCancelRemove,
+ onRequestRemove,
+ confirmRemove,
+ extCols,
+ expandedCells,
+ onToggleExpand,
+}: {
+ r: any
+ candleRows: KlineRow[]
+ showCandle: boolean
+ onPreview: (symbol: string, name: string) => void
+ onConfirmRemove: (symbol: string) => void
+ onCancelRemove: () => void
+ onRequestRemove: (symbol: string) => void
+ confirmRemove: string | null
+ extCols: ColumnConfig[]
+ expandedCells: Set
+ onToggleExpand: (key: string) => void
+}) {
+ const board = boardTag(r.symbol)
+ const price = r.rt_price ?? r.close
+ const pct = r.rt_pct ?? r.change_pct
+ const name = r.rt_name ?? r.name
+ const signals = getSignals(r)
+ const isUp = (pct ?? 0) > 0
+ const isDown = (pct ?? 0) < 0
+
+ // 动态背景渐变: 涨=红底, 跌=绿底, 平=无色
+ const bgGlow = isUp
+ ? 'bg-gradient-to-br from-bull/[0.06] via-transparent to-bull/[0.02]'
+ : isDown
+ ? 'bg-gradient-to-br from-bear/[0.06] via-transparent to-bear/[0.02]'
+ : ''
+ // 左侧指示条颜色
+ const barColor = isUp ? 'bg-bull/70' : isDown ? 'bg-bear/70' : 'bg-muted/30'
+ // 涨跌幅标签背景
+ const pctBg = isUp ? 'bg-bull/12 text-bull' : isDown ? 'bg-bear/12 text-bear' : 'bg-elevated text-secondary'
+
+ return (
+ onPreview(r.symbol, name ?? '')}
+ >
+ {/* 左侧彩色指示条 */}
+
+
+ {/* 删除按钮 / 确认区 */}
+
+ {confirmRemove === r.symbol ? (
+
e.stopPropagation()}>
+ onConfirmRemove(r.symbol)}
+ className="px-1.5 py-0.5 rounded text-[10px] text-danger bg-danger/10 hover:bg-danger/20 transition-colors"
+ >
+ 确认
+
+ onCancelRemove()} className="p-0.5 text-muted hover:text-foreground transition-colors">
+
+
+
+ ) : (
+
{ e.stopPropagation(); onRequestRemove(r.symbol) }}
+ className="opacity-0 group-hover:opacity-100 text-muted hover:text-danger transition-all duration-150 p-0.5 rounded hover:bg-elevated"
+ aria-label="移除"
+ >
+
+
+ )}
+
+
+ {/* 卡片内容 */}
+
+ {/* 第一行: 代码 + 名称 + 板块标识 */}
+
+
+ {r.symbol}
+
+ {name && (
+ {name}
+ )}
+ {board && (
+
+ {board.label}
+
+ )}
+ {r.consecutive_limit_ups > 0 && (
+
+ {r.consecutive_limit_ups === 1 ? '首板' : `${r.consecutive_limit_ups}连`}
+
+ )}
+
+
+ {/* 第二行: 大价格 + 涨跌幅胶囊 */}
+
+
+ {fmtPrice(price)}
+
+ {pct != null && (
+
+ {isUp ? '+' : ''}{pct.toFixed(2)}%
+
+ )}
+
+
+ {/* 第三行: 指标 */}
+
+ 换手{r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'}
+ 量比{fmtPrice(r.vol_ratio_5d)}
+ RSI{r.rsi_14 != null ? r.rsi_14.toFixed(1) : '—'}
+ {/* 扩展数据列展示在卡片中 */}
+ {extCols.map(col => {
+ if (col.source.type !== 'ext') return null
+ const { configId, fieldName } = col.source
+ const val = r[`${configId}__${fieldName}`]
+ if (val == null) return null
+
+ const cellKey = `${r.symbol}::${col.id}`
+ const expanded = expandedCells.has(cellKey)
+
+ return (
+
+ {fieldName}
+
+ {renderExtValue(val, col, expanded, () => onToggleExpand(cellKey), true)}
+
+
+ )
+ })}
+
+
+
+ {/* 信号标签区 */}
+ {signals.length > 0 && (
+
+ {signals.slice(0, 3).map(s => (
+
+ {s.label}
+
+ ))}
+ {signals.length > 3 && (
+
+ +{signals.length - 3}
+
+ )}
+
+ )}
+
+ {/* 迷你蜡烛图 */}
+ {showCandle && candleRows.length > 0 && (
+
+
+
+ )}
+
+ )
+}
+
+// ===== 主页面 =====
+
+export function Watchlist() {
+ const qc = useQueryClient()
+ const [viewMode, setViewMode] = useState<'table' | 'card'>(() => {
+ return (storage.watchlistView.get('table') as 'table' | 'card')
+ })
+ const [dailyKChartVisible, setDailyKChartVisible] = useState(() => {
+ return storage.watchlistCandle.get(true)
+ })
+
+ // 列配置 — 从后端/localStorage 异步加载
+ const [columns, setColumns] = useState([...BUILTIN_COLUMNS])
+ const [customizerOpen, setCustomizerOpen] = useState(false)
+ const columnsLoaded = useRef(false)
+
+ useEffect(() => {
+ if (columnsLoaded.current) return
+ columnsLoaded.current = true
+ loadColumnConfig().then(setColumns)
+ }, [])
+
+ const handleColumnsChange = useCallback((next: ColumnConfig[]) => {
+ setColumns(next)
+ saveColumnConfig(next)
+ }, [])
+
+ const candleColumn = useMemo(() =>
+ columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible),
+ [columns],
+ )
+ const candleColumnEnabled = !!candleColumn
+ // 日k列渲染配置(来自列定制,已钳制边界)
+ const candleResolved = useMemo(() => resolveCandleConfig(candleColumn?.candleConfig), [candleColumn])
+ const candleDays = candleResolved.days
+ const candleSize = dailyKChartVisible
+ ? { width: candleResolved.enabledWidth, height: candleResolved.enabledHeight }
+ : { width: candleResolved.disabledWidth, height: candleResolved.disabledHeight }
+
+ const dailyKVisible = candleColumnEnabled && dailyKChartVisible
+
+ // 计算可见列(列是否出现只由自定义列配置决定)
+ const visibleColumns = useMemo(() => {
+ return columns.filter(c => c.visible)
+ }, [columns])
+
+ // 计算 ext 列参数
+ const extColumnsParam = useMemo(() => buildExtColumnsParam(columns), [columns])
+
+ const toggleView = useCallback(() => {
+ setViewMode(v => {
+ const next = v === 'table' ? 'card' : 'table'
+ storage.watchlistView.set(next)
+ return next
+ })
+ }, [])
+ const toggleDailyKChart = useCallback(() => {
+ setDailyKChartVisible(v => {
+ const next = !v
+ storage.watchlistCandle.set(next)
+ return next
+ })
+ }, [])
+ const [previewSymbol, setPreviewSymbol] = useState(null)
+ const [previewName, setPreviewName] = useState('')
+ const [expandedCells, setExpandedCells] = useState>(new Set())
+ const closePreview = useCallback(() => {
+ setPreviewSymbol(null)
+ setPreviewName('')
+ }, [])
+
+ const handleToggleExpand = useCallback((cellKey: string) => {
+ setExpandedCells(prev => {
+ const next = new Set(prev)
+ if (next.has(cellKey)) next.delete(cellKey)
+ else next.add(cellKey)
+ return next
+ })
+ }, [])
+
+ const list = useQuery({
+ queryKey: QK.watchlist,
+ queryFn: api.watchlistList,
+ })
+
+ // enriched 数据 — 传入 ext_columns 参数
+ const enriched = useQuery({
+ queryKey: QK.watchlistEnriched(extColumnsParam),
+ queryFn: () => api.watchlistEnriched(extColumnsParam || undefined),
+ enabled: (list.data?.symbols.length ?? 0) > 0,
+ })
+
+ const symbols = enriched.data?.rows?.map((r: any) => r.symbol) ?? []
+ const symbolsKey = symbols.join(',')
+
+ // 批量日k数据 (天数由列配置决定)
+ const klineBatch = useQuery({
+ queryKey: QK.watchlistKlineBatch(`${symbolsKey}|${candleDays}`),
+ queryFn: () => api.klineDailyBatch(symbols, candleDays),
+ enabled: dailyKVisible && symbols.length > 0,
+ staleTime: 5 * 60_000, // 5 分钟内不重请求
+ })
+
+ const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {}
+
+ const addMutation = useMutation({
+ mutationFn: (sym: string) => api.watchlistAdd(sym),
+ onSuccess: (data) => {
+ qc.setQueryData(QK.watchlist, data)
+ qc.invalidateQueries({ queryKey: QK.watchlist })
+ qc.invalidateQueries({ queryKey: ['watchlist-enriched'] })
+ qc.invalidateQueries({ queryKey: ['watchlist-kline-batch'] })
+ },
+ })
+
+ const remove = useMutation({
+ mutationFn: (sym: string) => api.watchlistRemove(sym),
+ onSuccess: (_data, sym) => {
+ // 1. 立即从 enriched 缓存中移除该股票,UI 即时更新
+ qc.setQueryData(['watchlist-enriched', extColumnsParam], (old: any) => {
+ if (!old?.rows) return old
+ return { ...old, rows: old.rows.filter((r: any) => r.symbol !== sym) }
+ })
+ // 2. 清除 list 缓存,触发后台 refetch
+ qc.invalidateQueries({ queryKey: QK.watchlist })
+ qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
+ qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') })
+ },
+ })
+
+ const clearAll = useMutation({
+ mutationFn: () => api.watchlistClear(),
+ onSuccess: () => {
+ setConfirmClear(false)
+ // 立即清空 enriched 缓存
+ qc.setQueryData(['watchlist-enriched', extColumnsParam], { rows: [], as_of: null, elapsed_ms: 0 })
+ qc.invalidateQueries({ queryKey: QK.watchlist })
+ qc.invalidateQueries({ queryKey: QK.watchlistEnriched() })
+ qc.invalidateQueries({ queryKey: QK.watchlistKlineBatch('') })
+ },
+ })
+
+ // 二次确认状态
+ const [confirmClear, setConfirmClear] = useState(false)
+ const [confirmRemove, setConfirmRemove] = useState(null)
+
+ const allSymbols = list.data?.symbols?.map(s => s.symbol) ?? []
+ const rows = enriched.data?.rows ?? []
+
+ // ===== 筛选 =====
+ const [filterOpen, setFilterOpen] = useState(false)
+ const [filters, setFilters] = useState>({})
+
+ // 板块筛选(持久化)
+ const [boardFilter, setBoardFilter] = useState>(() => {
+ const saved = storage.watchlistBoardFilter.get([])
+ return saved.length > 0 ? new Set(saved) : new Set(BOARDS) // 默认全选
+ })
+ const persistBoardFilter = useCallback((next: Set) => {
+ setBoardFilter(next)
+ storage.watchlistBoardFilter.set([...next])
+ }, [])
+
+ const toggleBoard = useCallback((board: string) => {
+ setBoardFilter(prev => {
+ const next = new Set(prev)
+ if (next.has(board)) next.delete(board)
+ else next.add(board)
+ persistBoardFilter(next)
+ return next
+ })
+ }, [persistBoardFilter])
+
+ const updateFilter = useCallback((colId: string, patch: { min?: string; max?: string; text?: string }) => {
+ setFilters(prev => {
+ const next = { ...prev }
+ const existing = next[colId] || {}
+ const merged = { ...existing, ...patch }
+ if (!merged.min && !merged.max && !merged.text) {
+ delete next[colId]
+ } else {
+ next[colId] = merged
+ }
+ return next
+ })
+ }, [])
+
+ const clearFilters = useCallback(() => setFilters({}), [])
+
+ // 可筛选的内置列
+ const filterableBuiltinCols = useMemo(
+ () => columns.filter(c => c.source.type === 'builtin' && !UNSORTABLE_KEYS.has(c.source.key) && c.id !== 'builtin:symbol'),
+ [columns],
+ )
+
+ // 按类别索引(复用列配置的分组定义)
+ const colsByCategory = useMemo(() => {
+ const map: Record = {}
+ for (const cat of COLUMN_GROUPS) {
+ map[cat.label] = []
+ for (const key of cat.keys) {
+ const col = filterableBuiltinCols.find(c => c.source.type === 'builtin' && c.source.key === key)
+ if (col) map[cat.label].push({ id: col.id, label: col.label, col })
+ }
+ }
+ return map
+ }, [filterableBuiltinCols])
+
+ // 筛选 + 排序
+ const filteredRows = useMemo(() => {
+ // 板块筛选(全选时跳过)
+ let result = rows
+ if (boardFilter.size > 0 && boardFilter.size < BOARDS.length) {
+ result = result.filter(r => {
+ const board = getBoardType(r.symbol)
+ return board != null && boardFilter.has(board)
+ })
+ }
+ // 数值/文本筛选
+ const activeFilters = Object.entries(filters).filter(([, v]) => v.min || v.max || v.text)
+ if (activeFilters.length > 0) {
+ result = result.filter(r => {
+ for (const [colId, f] of activeFilters) {
+ const col = columns.find(c => c.id === colId)
+ if (!col) continue
+ const val = getSortValue(r, col)
+ if (val == null) return false
+ if (typeof val === 'number') {
+ if (f.min && val < Number(f.min)) return false
+ if (f.max && val > Number(f.max)) return false
+ } else {
+ if (f.text && !String(val).includes(f.text)) return false
+ }
+ }
+ return true
+ })
+ }
+ return result
+ }, [rows, filters, columns, boardFilter])
+
+ const activeFilterCount = Object.values(filters).filter(v => v.min || v.max || v.text).length
+
+ // 排序(复用共享三态排序 hook)
+ const { sort, toggle: handleSortToggle, sortRows } = useTableSort()
+
+ const sortedRows = useMemo(
+ () => sortRows(filteredRows, columns),
+ [filteredRows, sortRows, columns],
+ )
+
+ // 可见的 ext 列(卡片视图使用)
+ const visibleExtCols = useMemo(
+ () => visibleColumns.filter(c => c.source.type === 'ext'),
+ [visibleColumns]
+ )
+
+ return (
+
+
+ {/* 筛选 / 搜索 */}
+ setFilterOpen(v => !v)}
+ className={`inline-flex items-center justify-center h-8 w-8 rounded-btn transition-colors duration-150 ease-smooth ${
+ filterOpen || activeFilterCount > 0
+ ? 'bg-accent/15 text-accent hover:bg-accent/25'
+ : 'bg-elevated text-secondary hover:bg-elevated/80'
+ }`}
+ title={`筛选${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
+ >
+
+
+ { setPreviewSymbol(sym); setPreviewName(name) }}
+ existingSymbols={allSymbols as string[]}
+ onAdd={(sym) => addMutation.mutate(sym)}
+ />
+
+ {/* 视图 */}
+
+ {viewMode === 'table' ? :
}
+
+
+ {/* 自定义列 / 刷新 */}
+ setCustomizerOpen(true)}
+ className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth"
+ title="自定义列"
+ >
+
+
+ enriched.refetch()}
+ disabled={enriched.isFetching}
+ className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-elevated hover:bg-elevated/80 text-secondary hover:text-foreground transition-colors duration-150 ease-smooth disabled:opacity-50"
+ title="刷新"
+ >
+
+
+ {allSymbols.length > 0 && (
+ <>
+
+ setConfirmClear(true)}
+ className="inline-flex items-center justify-center h-8 w-8 rounded-btn bg-danger/10 text-danger hover:bg-danger/20 transition-colors duration-150 ease-smooth"
+ title="清空自选"
+ >
+
+
+ >
+ )}
+
+ }
+ />
+
+ {/* 筛选栏 */}
+ {filterOpen && (
+
+ {/* 板块筛选 */}
+
+
板块
+
+ {BOARDS.map(board => {
+ const active = boardFilter.has(board)
+ return (
+ toggleBoard(board)}
+ className={`px-2 py-0.5 rounded text-[11px] transition-colors ${
+ active
+ ? 'bg-accent/15 text-accent'
+ : 'bg-elevated text-secondary hover:text-foreground hover:bg-elevated/80'
+ }`}
+ >
+ {board}
+
+ )
+ })}
+
+
+ {COLUMN_GROUPS.map(cat => {
+ const items = colsByCategory[cat.label]?.filter(i => i.col)
+ if (!items?.length) return null
+ return (
+
+ )
+ })}
+ {activeFilterCount > 0 && (
+
+ 清除全部筛选
+
+ )}
+
+ )}
+
+ {/* 可滚动列表区 — 占满剩余高度,内部独立滚动,表头 sticky 固定 */}
+
+
+ {/* 列表 */}
+ {list.isLoading &&
加载中…
}
+ {list.isError &&
读取自选失败
}
+
+ {allSymbols.length === 0 ? (
+
+ ) : viewMode === 'table' ? (
+
r.symbol}
+ rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'}
+ // 日k列表头:标签 + 显示/隐藏眼睛按钮
+ renderHeaderContent={(col) => {
+ if (col.source.type === 'builtin' && col.source.key === 'candle') {
+ return (
+
+ {col.label}
+ { event.stopPropagation(); toggleDailyKChart() }}
+ className={`inline-flex items-center justify-center w-5 h-5 rounded transition-colors ${
+ dailyKChartVisible
+ ? 'text-accent bg-accent/10 hover:bg-accent/20'
+ : 'text-muted hover:text-foreground hover:bg-elevated'
+ }`}
+ title={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
+ aria-label={dailyKChartVisible ? '隐藏日k蜡烛' : '显示日k蜡烛'}
+ >
+ {dailyKChartVisible ? : }
+
+
+ )
+ }
+ return undefined
+ }}
+ renderCell={(r: any, col: ColumnConfig) => {
+ // ext 列
+ if (col.source.type === 'ext') {
+ return renderExtCell(r, col, expandedCells, handleToggleExpand)
+ }
+ const key = col.source.key
+ const price = r.rt_price ?? r.close
+ const pct = r.rt_pct ?? r.change_pct
+ const name = r.rt_name ?? r.name
+ // 自选页 symbol 列:预览 + 内嵌删除(减号图标,二次确认)
+ if (key === 'symbol') {
+ const board = boardTag(r.symbol)
+ return (
+
+
+
{ setPreviewSymbol(r.symbol); setPreviewName(name ?? '') }}
+ className="flex items-center gap-1 text-left min-w-0"
+ >
+
+ {r.symbol}
+
+ {name && (
+
+ {name}
+
+ )}
+ {board ? (
+
+ {board.label}
+
+ ) : null}
+
+ {/* 删除入口:默认减号图标,二次确认时替换为确定按钮 */}
+
+ {confirmRemove === r.symbol ? (
+
+ { remove.mutate(r.symbol); setConfirmRemove(null) }}
+ className="px-1.5 py-0.5 rounded text-[10px] text-danger bg-danger/10 hover:bg-danger/20 transition-colors"
+ >
+ 确认
+
+ setConfirmRemove(null)}
+ className="p-0.5 text-muted hover:text-foreground transition-colors"
+ >
+
+
+
+ ) : (
+
setConfirmRemove(r.symbol)}
+ className="p-0.5 text-muted hover:text-danger transition-colors duration-150 ease-smooth"
+ aria-label="移除"
+ >
+
+
+ )}
+
+
+
+ )
+ }
+ // 实时行情列:price/pct/amount 使用 rt_ 回退(自选页有实时推送)
+ const numCls = 'px-2 py-1.5 text-right num tabular-nums'
+ if (key === 'price') {
+ return {fmtPrice(price)}
+ }
+ if (key === 'pct') {
+ return {fmtPct(pct)}
+ }
+ if (key === 'amount') {
+ return {fmtBigNum(r.rt_amount ?? r.amount)}
+ }
+ if (key === 'turnover') {
+ return {r.turnover_rate != null ? `${r.turnover_rate.toFixed(2)}%` : '—'}
+ }
+ // 信号列
+ if (key === 'signals') {
+ const signals = getSignals(r)
+ return (
+
+ {signals.length > 0 && (
+
+ {signals.slice(0, 3).map((s) => (
+
+ {s.label}
+
+ ))}
+ {signals.length > 3 && (
+ +{signals.length - 3}
+ )}
+
+ )}
+
+ )
+ }
+ // 日k列
+ if (key === 'candle') {
+ return (
+
+
+
+ )
+ }
+ // 其余纯数据列 → 共享原语
+ return renderBuiltinDataCell(r, col)
+ }}
+ className="rounded-card overflow-x-auto"
+ />
+ ) : (
+
+ {rows.map((r: any) => (
+ { setPreviewSymbol(sym); setPreviewName(name) }}
+ onConfirmRemove={(sym) => { remove.mutate(sym); setConfirmRemove(null) }}
+ onCancelRemove={() => setConfirmRemove(null)}
+ onRequestRemove={(sym) => setConfirmRemove(sym)}
+ confirmRemove={confirmRemove}
+ extCols={visibleExtCols}
+ expandedCells={expandedCells}
+ onToggleExpand={handleToggleExpand}
+ />
+ ))}
+
+ )}
+
+
+
+ {/* 清空确认弹窗 */}
+
+ {confirmClear && (
+
+
setConfirmClear(false)}
+ />
+
+ 确认清空自选
+
+ 将移除全部 {allSymbols.length} 只自选股,此操作不可恢复。
+
+
+ setConfirmClear(false)}
+ className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
+ >
+ 取消
+
+ clearAll.mutate()}
+ disabled={clearAll.isPending}
+ className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
+ >
+ {clearAll.isPending ? '清除中...' : '确认清空'}
+
+
+
+
+ )}
+
+
+ {/* 列自定义侧栏 */}
+ setCustomizerOpen(false)}
+ />
+
+
+
+ )
+}
diff --git a/frontend/src/pages/backtest/FactorBacktest.tsx b/frontend/src/pages/backtest/FactorBacktest.tsx
new file mode 100644
index 0000000..3c44771
--- /dev/null
+++ b/frontend/src/pages/backtest/FactorBacktest.tsx
@@ -0,0 +1,447 @@
+import { useState, useMemo } from 'react'
+import { useQuery, useMutation } from '@tanstack/react-query'
+import { motion } from 'framer-motion'
+import { Play, BarChart3, Clock } from 'lucide-react'
+import { api, type FactorColumn, type FactorBacktestResult, type GroupStat } from '@/lib/api'
+import { fmtPct, priceColorClass } from '@/lib/format'
+import { EmptyState } from '@/components/EmptyState'
+import { DatePicker } from '@/components/DatePicker'
+import { FactorICChart } from './charts/FactorICChart'
+import { FactorGroupNavChart } from './charts/FactorGroupNavChart'
+
+const formatDate = (date: Date) => date.toISOString().slice(0, 10)
+const monthsAgo = (months: number) => {
+ const date = new Date()
+ date.setMonth(date.getMonth() - months)
+ return formatDate(date)
+}
+const TODAY = formatDate(new Date())
+const THREE_MONTHS_AGO = monthsAgo(3)
+
+const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
+ focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
+
+function StatCard({ label, value, highlight }: {
+ label: string
+ value: string | null | undefined
+ highlight?: 'bull' | 'bear' | 'neutral'
+}) {
+ const colorCls = highlight === 'bull'
+ ? 'text-bull' : highlight === 'bear' ? 'text-bear' : ''
+ return (
+
+
{label}
+
+ {value ?? '—'}
+
+
+ )
+}
+
+function LoadingPanel({ symbolsText }: { symbolsText: string }) {
+ return (
+
+
+
+
+
正在计算因子分析
+
{symbolsText} · 完成后会一次性刷新 IC、分层收益和净值曲线。
+
+
+
+
+
+
+
+ {['读取因子', '计算 IC', '分层回测', '汇总指标'].map(item => (
+
+ ))}
+
+
+
+
+
+
+ {[46, 38, 54, 50, 64, 58, 74, 68, 84, 78, 90, 86].map((h, i) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+export function FactorBacktest() {
+ const [factorName, setFactorName] = useState('momentum_20d')
+ const [symbols, setSymbols] = useState('')
+ const [start, setStart] = useState(THREE_MONTHS_AGO)
+ const [end, setEnd] = useState(TODAY)
+ const [nGroups, setNGroups] = useState(5)
+ const [weight, setWeight] = useState<'equal' | 'factor_weight'>('equal')
+ const [fees, setFees] = useState('2')
+ const [result, setResult] = useState(null)
+
+ const columns = useQuery({
+ queryKey: ['backtest-factor-columns'],
+ queryFn: api.factorColumns,
+ })
+
+ // 按 group 分类的因子
+ const factorGroups = useMemo(() => {
+ const cols = columns.data?.columns ?? []
+ const groups: Record = {}
+ for (const c of cols) {
+ ;(groups[c.group] ??= []).push(c)
+ }
+ return groups
+ }, [columns.data])
+
+ // 当前因子描述
+ const factorDesc = useMemo(() => {
+ return columns.data?.columns.find(c => c.id === factorName)?.desc ?? ''
+ }, [columns.data, factorName])
+
+ const run = useMutation({
+ mutationFn: () =>
+ api.factorRun({
+ factor_name: factorName,
+ symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
+ start: start || null,
+ end: end || undefined,
+ n_groups: nGroups,
+ rebalance: 'daily',
+ weight,
+ fees_pct: Number(fees) / 10000,
+ }),
+ onSuccess: (data) => {
+ if (data.error) {
+ setResult(data)
+ } else {
+ setResult(data)
+ }
+ },
+ })
+
+ const applyRange = (months: number) => {
+ setStart(monthsAgo(months))
+ setEnd(formatDate(new Date()))
+ }
+
+ const applyAllRange = () => {
+ setStart('')
+ setEnd(formatDate(new Date()))
+ }
+
+ const rangeKey = end === TODAY && start === THREE_MONTHS_AGO
+ ? '3m'
+ : end === TODAY && start === monthsAgo(6)
+ ? '6m'
+ : end === TODAY && start === monthsAgo(12)
+ ? '1y'
+ : end === TODAY && start === ''
+ ? 'all'
+ : 'custom'
+ const rangeTitle = rangeKey === '3m'
+ ? '近 3 个月'
+ : rangeKey === '6m'
+ ? '近 6 个月'
+ : rangeKey === '1y'
+ ? '近 1 年'
+ : rangeKey === 'all'
+ ? '全部历史'
+ : '自定义区间'
+ const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
+ ? 'bg-accent/15 text-accent'
+ : 'text-muted hover:bg-elevated/70 hover:text-secondary'
+ }`
+
+ return (
+
+ {/* 配置面板 */}
+
+
+
因子配置
+
选择因子、区间和分组方式。默认最近 3 个月。
+
+
+
+
因子
+
setFactorName(e.target.value)}
+ className={INPUT_CLS}
+ >
+ {Object.entries(factorGroups).map(([group, cols]) => (
+
+ {cols.map(c => (
+ {c.label}
+ ))}
+
+ ))}
+
+ {factorDesc && (
+
{factorDesc}
+ )}
+
+
+
+
+ 标的(逗号分隔,留空=全市场)
+
+ setSymbols(e.target.value)}
+ placeholder="留空则使用全市场,建议最近3个月"
+ className={`w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs font-mono
+ focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`}
+ />
+
+
+
+
+
回测区间
+
+ {rangeTitle}
+
+
+
+
+
+
+ applyRange(3)} className={`${rangeButtonCls('3m')} flex-1`}>3个月
+ applyRange(6)} className={`${rangeButtonCls('6m')} flex-1`}>6个月
+ applyRange(12)} className={`${rangeButtonCls('1y')} flex-1`}>1年
+ 全部
+
+
+
+
+
+ 分组数
+ setNGroups(Number(e.target.value))} className={INPUT_CLS}>
+ 3组
+ 5组
+ 10组
+
+
+
+ 权重
+ setWeight(e.target.value as any)} className={INPUT_CLS}>
+ 等权
+ 因子加权
+
+
+
+ 佣金(万分之)
+ setFees(e.target.value)}
+ className={INPUT_CLS} />
+
+
+
+ run.mutate()}
+ disabled={run.isPending}
+ className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-btn
+ bg-accent text-sm font-medium hover:bg-accent/90
+ transition-colors duration-150 ease-smooth disabled:opacity-50"
+ >
+
+ {run.isPending ? '分析中…' : '开始因子分析'}
+
+
+
+ {/* 结果面板 */}
+
+ {result?.error && !result.ic_mean && (
+
+ {result.error}
+
+ )}
+
+ {run.isError && (
+
+ {String((run.error as any).message)}
+
+ )}
+
+ {!result && !run.isPending && (
+
+ )}
+
+ {run.isPending && result && (
+
+ 正在重新计算,当前暂时展示上一次因子分析结果,完成后会自动替换。
+
+ )}
+
+ {run.isPending && !result && (
+
+ )}
+
+ {result && result.ic_mean != null && (
+
+ {/* IC/IR 指标 */}
+
+
+
因子预测能力
+
+
+ Rank IC · 日度调仓
+
+ {result.elapsed_ms > 0 && (
+
+
+ {result.elapsed_ms.toFixed(0)} ms
+
+ )}
+
+
+
+ 0.03 ? 'bull' : result.ic_mean < -0.03 ? 'bear' : 'neutral'
+ : undefined}
+ />
+
+ 0.5 ? (result.ir > 0 ? 'bull' : 'bear') : 'neutral'
+ : undefined}
+ />
+
+
+
+
+ {/* IC 时序图 */}
+ {result.ic_series.length > 0 && (
+
+ )}
+
+ {/* 分层净值 */}
+ {result.group_nav.length > 0 && (
+
+ )}
+
+ {/* 分层统计表 */}
+ {result.group_stats.length > 0 && (
+
+
+
+
+ 分组
+ 总收益
+ 年化
+ 最大回撤
+ 夏普
+ 胜率
+
+
+
+ {result.group_stats.map((g: GroupStat) => (
+
+ {g.label}
+
+ {fmtPct(g.total_return)}
+
+
+ {fmtPct(g.annual_return)}
+
+ {fmtPct(g.max_drawdown)}
+ {g.sharpe?.toFixed(2)}
+ {fmtPct(g.win_rate)}
+
+ ))}
+ {/* 多空行 */}
+ {result.long_short_stats?.total_return != null && (
+
+
+ 多空({result.long_short_stats.top_group ?? ''}-{result.long_short_stats.bottom_group ?? ''})
+
+
+ {fmtPct(result.long_short_stats.total_return as number)}
+
+ —
+
+ {fmtPct(result.long_short_stats.max_drawdown as number)}
+
+ —
+ —
+
+ )}
+
+
+
+ )}
+
+ {/* 数据概要 */}
+
+ {result.n_symbols} 只标的
+ {result.n_dates} 个交易日
+ run_id: {result.run_id}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx
new file mode 100644
index 0000000..2975c8a
--- /dev/null
+++ b/frontend/src/pages/backtest/StrategyBacktest.tsx
@@ -0,0 +1,2213 @@
+import { useState, useMemo, useEffect, useRef, type ReactNode } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { motion } from 'framer-motion'
+import { Play, FlaskConical, Clock, Loader2, Square, Search, Plus, X, SlidersHorizontal, BarChart3, Gauge, Zap } from 'lucide-react'
+import {
+ api,
+ type StrategyBacktestResult,
+ type StrategyBacktestTrade,
+ type StrategyDetail,
+ type StrategyParamDef,
+} from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+import { storage } from '@/lib/storage'
+import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
+import { boardTag } from '@/lib/board'
+import { BUILTIN_COLUMNS } from '@/lib/watchlist-columns'
+import { startBacktest, stopBacktest, tryReconnect, useBacktestTask } from '@/lib/backtestTask'
+import { useDataStatus, useCapabilities } from '@/lib/useSharedQueries'
+import { EmptyState } from '@/components/EmptyState'
+import { DatePicker } from '@/components/DatePicker'
+import { StrategyNavChart } from './charts/StrategyNavChart'
+import { ReturnDistributionChart } from './charts/ReturnDistributionChart'
+import { TradeKlineModal } from './components/TradeKlineModal'
+
+const formatDate = (date: Date) => date.toISOString().slice(0, 10)
+const monthsAgo = (months: number) => {
+ const date = new Date()
+ date.setMonth(date.getMonth() - months)
+ return formatDate(date)
+}
+const TODAY = formatDate(new Date())
+const THREE_MONTHS_AGO = monthsAgo(3)
+
+type QuickRangeUnit = 'month' | 'year' | 'all'
+type QuickRangeConfig = { id: string; enabled: boolean; unit: QuickRangeUnit; value: number }
+
+const QUICK_RANGE_LIMITS = {
+ month: { min: 1, max: 120 },
+ year: { min: 1, max: 10 },
+} as const
+const DEFAULT_QUICK_RANGES: QuickRangeConfig[] = [
+ { id: 'range-1', enabled: true, unit: 'month', value: 3 },
+ { id: 'range-2', enabled: true, unit: 'month', value: 6 },
+ { id: 'range-3', enabled: true, unit: 'year', value: 1 },
+ { id: 'range-4', enabled: true, unit: 'all', value: 0 },
+]
+const quickRangeValue = (unit: QuickRangeUnit, value: unknown, fallback: number) => {
+ if (unit === 'all') return 0
+ const limits = QUICK_RANGE_LIMITS[unit]
+ const num = Number(value)
+ const safe = Number.isFinite(num) ? Math.round(num) : fallback
+ return clamp(safe, limits.min, limits.max)
+}
+const normalizeQuickRange = (raw: unknown, fallback: QuickRangeConfig): QuickRangeConfig => {
+ const obj = raw && typeof raw === 'object' ? raw as Partial : {}
+ const unit: QuickRangeUnit = obj.unit === 'month' || obj.unit === 'year' || obj.unit === 'all'
+ ? obj.unit
+ : fallback.unit
+ const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : fallback.enabled
+ return { id: fallback.id, enabled, unit, value: quickRangeValue(unit, obj.value, fallback.value) }
+}
+const normalizeQuickRanges = (raw: unknown) => {
+ const items = Array.isArray(raw) ? raw : []
+ const ranges = DEFAULT_QUICK_RANGES.map((fallback, index) => {
+ const byId = items.find(item => item && typeof item === 'object' && (item as { id?: unknown }).id === fallback.id)
+ return normalizeQuickRange(byId ?? items[index], fallback)
+ })
+ return ranges.some(range => range.enabled)
+ ? ranges
+ : ranges.map((range, index) => index === 0 ? { ...range, enabled: true } : range)
+}
+const loadQuickRanges = () => normalizeQuickRanges(storage.strategyBacktestQuickRanges.get(DEFAULT_QUICK_RANGES))
+const quickRangeMonths = (range: QuickRangeConfig) => range.unit === 'year' ? range.value * 12 : range.value
+const quickRangeLabel = (range: QuickRangeConfig) => range.unit === 'all'
+ ? '全部'
+ : range.unit === 'year'
+ ? `${range.value}年`
+ : `${range.value}个月`
+const quickRangeTitle = (range: QuickRangeConfig) => range.unit === 'all'
+ ? '全部历史'
+ : range.unit === 'year'
+ ? `近 ${range.value} 年`
+ : `近 ${range.value} 个月`
+
+const INPUT_CLS = `w-full px-2.5 py-1.5 rounded-input bg-surface border border-border text-xs
+ focus:outline-none focus:border-accent transition-colors duration-150 ease-smooth`
+
+const SRC_MAP: Record = { builtin: '内置', custom: '自定义', ai: 'AI' }
+const TRADE_PAGE_SIZE_OPTIONS = [10, 20, 30, 50, 100]
+const BADGE_CLS_MAP: Record = {
+ builtin: 'bg-secondary/10 text-muted border-border',
+ ai: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
+ custom: 'bg-amber-400/10 text-amber-400 border-amber-400/30',
+}
+const FIELD_LABEL: Record = {}
+for (const c of BUILTIN_COLUMNS) {
+ if (c.source.type === 'builtin') FIELD_LABEL[c.source.key] = c.label
+}
+Object.assign(FIELD_LABEL, {
+ change_pct: '涨跌幅', consecutive_limit_ups: '连板',
+ momentum_60d: '60D动量', turnover_rate: '换手率',
+ rsi_14: 'RSI14', rsi_6: 'RSI6', rsi_24: 'RSI24',
+ vol_ratio_5d: '量比', vol_ratio_20d: '20日量比',
+ macd_dif: 'MACD-DIF', macd_dea: 'MACD-DEA', macd_hist: 'MACD柱',
+ boll_upper: '布林上轨', boll_lower: '布林下轨',
+})
+const SIGNAL_LABELS: Record = {
+ signal_ma_golden_5_20: 'MA5上穿MA20',
+ signal_ma_dead_5_20: 'MA5下穿MA20',
+ signal_ma_golden_20_60: 'MA20上穿MA60',
+ signal_macd_golden: 'MACD金叉',
+ signal_macd_dead: 'MACD死叉',
+ signal_ma20_breakout: '突破MA20',
+ signal_ma20_breakdown: '跌破MA20',
+ signal_n_day_high: '60日新高',
+ signal_n_day_low: '60日新低',
+ signal_boll_breakout_upper: '突破布林上轨',
+ signal_boll_breakdown_lower: '跌破布林下轨',
+ signal_volume_surge: '放量',
+ signal_limit_up: '涨停',
+ signal_limit_down: '跌停',
+ signal_limit_down_recovery: '跌停翘板',
+ signal_broken_board_recovery: '断板反包',
+}
+const SIGNAL_OPTIONS = Object.keys(SIGNAL_LABELS)
+const BOARD_OPTIONS = ['沪主板', '深主板', '创业板', '科创板', '北交所']
+const BASIC_FILTER_FIELDS = [
+ { key: 'price_min', label: '最低价', unit: '元' },
+ { key: 'price_max', label: '最高价', unit: '元' },
+ { key: 'amount_min', label: '最低成交额', unit: '亿', scale: 1e8 },
+ { key: 'market_cap_min', label: '最低总市值', unit: '亿', scale: 1e8 },
+ { key: 'turnover_min', label: '最低换手率', unit: '%' },
+ { key: 'turnover_max', label: '最高换手率', unit: '%' },
+]
+type AdvancedSettingsTab = 'params' | 'filter' | 'entry' | 'exit' | 'scoring' | 'risk' | 'range'
+type StrategyGroup = 'all' | 'custom' | 'ai' | 'builtin'
+const STRATEGY_GROUPS: { id: StrategyGroup; label: string }[] = [
+ { id: 'all', label: '全部' },
+ { id: 'custom', label: '自定义' },
+ { id: 'ai', label: 'AI' },
+ { id: 'builtin', label: '内置' },
+]
+const ADVANCED_TABS: { id: AdvancedSettingsTab; label: string }[] = [
+ { id: 'params', label: '策略参数' },
+ { id: 'filter', label: '基础过滤' },
+ { id: 'entry', label: '买入触发器' },
+ { id: 'exit', label: '卖出触发器' },
+ { id: 'scoring', label: '评分权重' },
+ { id: 'risk', label: '风控' },
+ { id: 'range', label: '回测范围' },
+]
+const toSignalId = (sig: string) => (sig.startsWith('signal_') || sig.startsWith('csg_')) ? sig : `signal_${sig}`
+const numOrNull = (v: string) => v === '' || Number.isNaN(Number(v)) ? null : Number(v)
+const clamp = (v: number, min?: number, max?: number) => {
+ let next = v
+ if (min != null) next = Math.max(next, min)
+ if (max != null) next = Math.min(next, max)
+ return next
+}
+const strategyDefaultParams = (detail: StrategyDetail) => {
+ const values: Record = { ...detail.params_defaults }
+ detail.params.forEach(p => {
+ if (!(p.id in values)) values[p.id] = p.default
+ })
+ return values
+}
+const buildDefaultOverrides = (detail: StrategyDetail) => ({
+ basic_filter: { ...detail.basic_filter },
+ entry_signals: detail.entry_signals.map(toSignalId),
+ exit_signals: detail.exit_signals.map(toSignalId),
+ scoring: { ...detail.scoring },
+ stop_loss: detail.stop_loss,
+ trailing_stop: detail.trailing_stop,
+ trailing_take_profit_activate: detail.trailing_take_profit_activate,
+ trailing_take_profit_drawdown: detail.trailing_take_profit_drawdown,
+ score_min: null,
+ score_max: null,
+ max_hold_days: detail.max_hold_days,
+})
+
+const fmtMoney = (v: number | null | undefined) => {
+ if (v == null || Number.isNaN(v)) return '—'
+ return v.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
+const fmtSignedMoney = (v: number | null | undefined) => {
+ if (v == null || Number.isNaN(v)) return '—'
+ const sign = v > 0 ? '+' : ''
+ return `${sign}${fmtMoney(v)}`
+}
+
+const fmtShares = (v: number | null | undefined) => {
+ if (v == null || Number.isNaN(v)) return '—'
+ return v.toLocaleString('zh-CN', { maximumFractionDigits: 0 })
+}
+
+const fmtLots = (v: number | null | undefined) => {
+ if (v == null || Number.isNaN(v)) return '—'
+ return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
+}
+
+const statValueColor = (v: number | null | undefined) => {
+ if (v == null || Number.isNaN(v) || v === 0) return '#f8fafc'
+ return v > 0 ? '#f87171' : '#34d399'
+}
+
+function ExitReasonBadge({ reason }: { reason: string }) {
+ const config: Record = {
+ signal: { label: '信号', cls: 'bg-accent/10 text-accent border-accent/30' },
+ stop_loss: { label: '止损', cls: 'bg-red-500/10 text-red-400 border-red-500/30' },
+ trailing_stop: { label: '移损', cls: 'bg-orange-500/10 text-orange-400 border-orange-500/30' },
+ trailing_take_profit: { label: '回撤止盈', cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
+ max_hold: { label: '超期', cls: 'bg-amber-400/10 text-amber-400 border-amber-400/30' },
+ pending_exit: { label: '待卖', cls: 'bg-orange-400/10 text-orange-400 border-orange-400/30' },
+ end: { label: '期末', cls: 'bg-secondary/10 text-secondary border-border' },
+ }
+ const c = config[reason] ?? { label: reason, cls: 'bg-elevated text-muted border-border' }
+ return (
+ {c.label}
+ )
+}
+
+type DailyTradeRow = {
+ date: string
+ buys: StrategyBacktestTrade[]
+ sells: StrategyBacktestTrade[]
+ buyValue: number
+ sellValue: number
+ realizedPnl: number
+ cumulativePnl: number
+}
+
+function fmtPositionPct(v: number | null | undefined, digits = 2): string {
+ if (v == null || Number.isNaN(v)) return '—'
+ return `${(Math.abs(v) * 100).toFixed(digits)}%`
+}
+
+function fmtScore(v: number | null | undefined): string {
+ if (v == null || Number.isNaN(Number(v))) return '—'
+ return Number(v).toFixed(1)
+}
+
+function DailyTradeChip({ trade, side, strategyName, onClick }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell'; strategyName?: string; onClick?: () => void }) {
+ const isBuy = side === 'buy'
+ const tag = boardTag(trade.symbol)
+ const price = isBuy ? trade.entry_price : trade.exit_price
+ const amount = isBuy ? trade.entry_value : trade.exit_value
+ const pnlColor = priceColorClass(trade.pnl_amount ?? trade.pnl_pct)
+ const footerColor = isBuy ? 'text-secondary' : pnlColor
+ const footerText = `仓位 ${fmtPositionPct(trade.position_pct, 2)}`
+ const scoreText = fmtScore(trade.entry_score)
+ const buyStrategy = strategyName || '策略'
+
+ return (
+
+
+
+ {isBuy ? '买' : '卖'}
+
+ {trade.name || trade.symbol}
+ {tag && {tag} }
+
+
+
+ {trade.symbol}
+ ·
+ {fmtLots(trade.lots)}手
+
+ {isBuy ? (
+ {fmtPrice(price)}
+ ) : (
+
+ {fmtPrice(price)}
+
+
+ )}
+
+ {isBuy ? (
+ <>
+
+ 策略 {buyStrategy}
+
+ 评分 {scoreText}
+
+
+
+ {fmtMoney(amount)}
+ {footerText}
+
+ >
+ ) : (
+ <>
+
+ 卖出
+ {fmtMoney(amount)}
+
+
+ 盈亏
+
+ {fmtSignedMoney(trade.pnl_amount)}
+ /
+ {fmtPct(trade.pnl_pct)}
+
+
+ >
+ )}
+
+ )
+}
+
+function TradeLegCell({ trade, side }: { trade: StrategyBacktestTrade; side: 'buy' | 'sell' }) {
+ const isBuy = side === 'buy'
+ const date = String(isBuy ? trade.entry_date : trade.exit_date).slice(0, 10)
+ const signalDate = String(isBuy ? trade.entry_signal_date ?? '' : trade.exit_signal_date ?? '').slice(0, 10)
+ const price = isBuy ? trade.entry_price : trade.exit_price
+ const amount = isBuy ? trade.entry_value : trade.exit_value
+
+ return (
+
+
+ {date}
+
+ {isBuy ? '买' : '卖'}
+
+
+
+ {fmtPrice(price)}
+ {fmtMoney(amount)}
+
+ {signalDate && signalDate !== date && (
+
信号 {signalDate}
+ )}
+
+ )
+}
+
+function fmtDuration(ms: number): string {
+ const s = ms / 1000
+ if (s < 1) return `${ms.toFixed(0)}ms`
+ if (s < 60) return `${s.toFixed(1)}秒`
+ const m = Math.floor(s / 60)
+ const rest = Math.round(s % 60)
+ return `${m}分${rest}秒`
+}
+
+function SharpeLabel() {
+ const [open, setOpen] = useState(false)
+ const [alignRight, setAlignRight] = useState(false)
+ const ref = useRef(null)
+ useEffect(() => {
+ if (!open) return
+ const onClick = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', onClick)
+ return () => document.removeEventListener('mousedown', onClick)
+ }, [open])
+ const toggle = () => {
+ if (!open && ref.current) {
+ const rect = ref.current.getBoundingClientRect()
+ setAlignRight(rect.left + 240 > window.innerWidth)
+ }
+ setOpen(o => !o)
+ }
+ return (
+
+ 夏普
+
+ ?
+
+ {open && (
+
+ 夏普比率 (Sharpe Ratio)
+ 衡量单位波动风险 换来的超额收益。
+ 数值越高,收益相对波动越优秀;
+ 短周期或交易次数少时容易偏高,仅供参考。
+
+ )}
+
+ )
+}
+
+function Stat({ label, value, color }: { label: ReactNode; value: string; color?: string }) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ )
+}
+
+function ConfigSection({ title, hint, children }: { title: string; hint?: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {title}
+ {hint && {hint} }
+
+
{children}
+
+ )
+}
+
+const scoringToPct = (values: Record) => {
+ const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
+ if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record
+ return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, Math.round((Math.max(0, Number(v) || 0) / total) * 100)])) as Record
+}
+
+const normalizePctWeights = (values: Record) => {
+ const total = Object.values(values).reduce((a, b) => a + Math.max(0, Number(b) || 0), 0)
+ if (total <= 0) return Object.fromEntries(Object.keys(values).map(k => [k, 0])) as Record
+ return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, +(Math.max(0, Number(v) || 0) / total).toFixed(4)])) as Record
+}
+
+function ScoringWeightRow({ name, weight, pct, editing, onChange }: {
+ name: string
+ weight: number
+ pct: number
+ editing: boolean
+ onChange: (value: number) => void
+}) {
+ const label = FIELD_LABEL[name] ?? name
+ return (
+
+
{label}
+ {editing ? (
+
onChange(Number(e.target.value))}
+ className="h-1 flex-1 cursor-pointer accent-amber-400"
+ />
+ ) : (
+
+ )}
+
{editing ? weight : `${pct}%`}
+
+ )
+}
+
+function StrategyParamInput({ param, value, onChange }: {
+ param: StrategyParamDef
+ value: any
+ onChange: (value: any) => void
+}) {
+ if (param.type === 'bool') {
+ const checked = value === true || value === 'true' || value === 'True' || value === true
+ return (
+
+ {param.label}
+ onChange(!checked)}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 cursor-pointer ${
+ checked ? 'bg-accent shadow-[0_0_6px_rgba(59,130,246,0.3)]' : 'bg-elevated'
+ }`}
+ aria-pressed={checked}
+ >
+
+
+
+ )
+ }
+ if (param.type === 'select') {
+ return (
+
+ {param.label}
+ onChange(e.target.value)} className={INPUT_CLS}>
+ {(param.options ?? []).map(opt => {opt} )}
+
+
+ )
+ }
+ return (
+
+ {param.label}
+ {
+ const n = numOrNull(e.target.value)
+ if (n == null) return onChange('')
+ const next = clamp(n, param.min, param.max)
+ onChange(param.type === 'int' ? Math.round(next) : next)
+ }}
+ className={INPUT_CLS}
+ />
+
+ )
+}
+
+function StockPoolPicker({ value, onChange }: { value: string; onChange: (value: string) => void }) {
+ const symbols = useMemo(() => value.split(',').map(s => s.trim()).filter(Boolean), [value])
+ const [query, setQuery] = useState('')
+ const [open, setOpen] = useState(false)
+ const [symbolNames, setSymbolNames] = useState>({})
+ const ref = useRef(null)
+ const search = useQuery({
+ queryKey: QK.instrumentSearch(query),
+ queryFn: () => api.instrumentSearch(query),
+ enabled: query.trim().length > 0,
+ staleTime: 30_000,
+ })
+ const results = search.data?.results ?? []
+
+ useEffect(() => {
+ if (results.length === 0) return
+ setSymbolNames(prev => {
+ const next = { ...prev }
+ results.forEach(r => {
+ if (r.name) next[r.symbol] = r.name
+ })
+ return next
+ })
+ }, [results])
+
+ useEffect(() => {
+ const onClick = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
+ }
+ document.addEventListener('mousedown', onClick)
+ return () => document.removeEventListener('mousedown', onClick)
+ }, [])
+
+ const setSymbols = (next: string[]) => onChange(Array.from(new Set(next)).join(','))
+ const addSymbol = (symbol: string, name?: string | null) => {
+ if (name) setSymbolNames(prev => ({ ...prev, [symbol]: name }))
+ setSymbols([...symbols, symbol])
+ setQuery('')
+ setOpen(false)
+ }
+ const removeSymbol = (symbol: string) => setSymbols(symbols.filter(s => s !== symbol))
+
+ return (
+
+
+
+
{ setQuery(e.target.value); setOpen(true) }}
+ onFocus={() => { if (query.trim()) setOpen(true) }}
+ placeholder="搜索股票名称/代码添加股票池"
+ className="w-full rounded-input border border-border bg-surface py-1.5 pl-8 pr-2.5 text-xs focus:border-accent focus:outline-none"
+ />
+ {open && results.length > 0 && (
+
+ {results.map(r => {
+ const added = symbols.includes(r.symbol)
+ return (
+
addSymbol(r.symbol, r.name)}
+ className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${added ? 'cursor-default text-muted' : 'text-foreground hover:bg-elevated'}`}
+ >
+ {r.symbol}
+ {r.name}
+
+
+ )
+ })}
+
+ )}
+
+
+ {symbols.length === 0 ? (
+ 留空 = 全市场,由基础过滤和策略条件筛选。
+ ) : symbols.map(symbol => {
+ const name = symbolNames[symbol]
+ return (
+
+ {symbol}
+ {name && {name} }
+ removeSymbol(symbol)} className="text-accent/70 hover:text-accent">
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export function StrategyBacktest() {
+ const [saved] = useState(() => storage.strategyBacktestLast.get(null))
+ const [selectedStrategy, setSelectedStrategy] = useState(saved?.selectedStrategy ?? null)
+ const [strategyGroup, setStrategyGroup] = useState('all')
+ const [symbols, setSymbols] = useState(saved?.symbols ?? '')
+ const [start, setStart] = useState(saved?.start ?? THREE_MONTHS_AGO)
+ const [end, setEnd] = useState(saved?.end ?? TODAY)
+ const [matching, setMatching] = useState<'close_t' | 'open_t+1'>(saved?.matching ?? 'open_t+1')
+ const [fees, setFees] = useState(saved?.fees ?? '2')
+ const [maxPositions, setMaxPositions] = useState(saved?.maxPositions ?? '10')
+ const [maxExposure, setMaxExposure] = useState(saved?.maxExposure ?? '100')
+ const [initialCapital, setInitialCapital] = useState(saved?.initialCapital ?? '1000000')
+ const [positionSizing, setPositionSizing] = useState<'equal' | 'score_weight'>(saved?.positionSizing ?? 'equal')
+ const [simMode, setSimMode] = useState<'position' | 'full'>(saved?.mode ?? 'position')
+ const [holdingDays, setHoldingDays] = useState(saved?.holdingDays ?? '5')
+ const [settingsOpen, setSettingsOpen] = useState(false)
+ // 高颗粒回测(分钟K精确回测)— 开发中,Starter+ 功能
+ const [highGranularity, setHighGranularity] = useState(false)
+ const { data: caps } = useCapabilities()
+ const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
+ const [rangeSettingsOpen, setRangeSettingsOpen] = useState(false)
+ const [quickRanges, setQuickRanges] = useState(loadQuickRanges)
+ const [settingsTab, setSettingsTab] = useState('params')
+ const [editingScoring, setEditingScoring] = useState(false)
+ const [scoringDraft, setScoringDraft] = useState>({})
+ const [strategyParams, setStrategyParams] = useState>(saved?.params ?? {})
+ const [overrides, setOverrides] = useState>(saved?.overrides ?? {})
+ const [result, setResult] = useState(saved?.result ?? null)
+ const [resultTab, setResultTab] = useState<'daily' | 'trades' | 'picks'>('daily')
+ const [dailyPage, setDailyPage] = useState(0)
+ const [tradePage, setTradePage] = useState(0)
+ const [tradePageSize, setTradePageSize] = useState(10)
+ const [selectedTrade, setSelectedTrade] = useState(null)
+ const loadedStrategyRef = useRef(null)
+
+ const strategies = useQuery({
+ queryKey: QK.screenerStrategies,
+ queryFn: api.screenerStrategies,
+ })
+
+ const strategyList = useMemo(() => strategies.data?.presets ?? [], [strategies.data])
+ const filteredStrategyList = useMemo(() => (
+ strategyGroup === 'all' ? strategyList : strategyList.filter(st => st.source === strategyGroup)
+ ), [strategyGroup, strategyList])
+
+ const strategyDetail = useQuery({
+ queryKey: ['strategy-detail', selectedStrategy],
+ queryFn: () => api.strategyGet(selectedStrategy!),
+ enabled: !!selectedStrategy,
+ })
+
+ const backtestTask = useBacktestTask()
+ const isPending = backtestTask?.isPending ?? false
+
+ const dataStatus = useDataStatus()
+ const earliestDate = dataStatus.data?.daily?.earliest_date ?? null
+
+ const resetConfigFromDetail = (detail: StrategyDetail) => {
+ setStrategyParams(strategyDefaultParams(detail))
+ setOverrides(buildDefaultOverrides(detail))
+ }
+
+ // 刷新页面后: 从 localStorage 恢复未完成的回测任务
+ useEffect(() => {
+ tryReconnect()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ useEffect(() => {
+ const detail = strategyDetail.data
+ if (!detail || loadedStrategyRef.current === detail.id) return
+ loadedStrategyRef.current = detail.id
+ if (saved?.selectedStrategy === detail.id && (saved.params || saved.overrides)) {
+ setStrategyParams(saved.params ?? strategyDefaultParams(detail))
+ setOverrides(saved.overrides ?? buildDefaultOverrides(detail))
+ return
+ }
+ resetConfigFromDetail(detail)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [strategyDetail.data])
+
+ // 当全局回测任务完成时, 把结果写入组件 (切页回来也能恢复)
+ useEffect(() => {
+ if (backtestTask && !backtestTask.isPending && backtestTask.result) {
+ setResult(backtestTask.result)
+ setResultTab('daily')
+ setDailyPage(0)
+ setTradePage(0)
+ storage.strategyBacktestLast.set({
+ selectedStrategy,
+ symbols,
+ start,
+ end,
+ matching,
+ fees,
+ maxPositions,
+ maxExposure,
+ initialCapital,
+ positionSizing,
+ mode: simMode,
+ holdingDays,
+ params: strategyParams,
+ overrides,
+ result: backtestTask.result,
+ })
+ }
+ }, [backtestTask])
+
+ const handleRun = () => {
+ if (!selectedStrategy) return
+ startBacktest({
+ strategy_id: selectedStrategy,
+ symbols: symbols ? symbols.split(',').map(s => s.trim()).filter(Boolean) : null,
+ start: start || null,
+ end: end || undefined,
+ matching,
+ fees_pct: Number(fees) / 10000,
+ max_positions: Number(maxPositions),
+ max_exposure_pct: Number(maxExposure) / 100,
+ initial_capital: Number(initialCapital),
+ position_sizing: positionSizing,
+ params: strategyParams,
+ overrides,
+ mode: simMode,
+ holding_days: Number(holdingDays) || 5,
+ })
+ }
+
+ // 提取统计
+ const s = result?.stats
+ const pick = (...keys: string[]) => {
+ for (const k of keys) {
+ if (s && k in s && s[k] != null) return s[k]
+ }
+ return null
+ }
+
+ const benchmarkReturn = useMemo(() => {
+ const values = (result?.benchmark_curve ?? [])
+ .map(r => Number(r.close ?? r.value))
+ .filter(v => Number.isFinite(v) && v > 0)
+ if (values.length < 2) return null
+ return values[values.length - 1] / values[0] - 1
+ }, [result?.benchmark_curve])
+
+ const strategyReturn = pick('total_return') as number | null
+ const excessReturn = strategyReturn != null && benchmarkReturn != null
+ ? strategyReturn - benchmarkReturn
+ : null
+
+ const applyRange = (months: number) => {
+ setStart(monthsAgo(months))
+ setEnd(formatDate(new Date()))
+ }
+
+ const applyAllRange = () => {
+ setStart(earliestDate ?? '')
+ setEnd(formatDate(new Date()))
+ }
+
+ // 进入页面/还在加载时就点了"全部": earliestDate 就绪后回填, 让 DatePicker 显示真实起始日
+ useEffect(() => {
+ if (earliestDate && start === '' && end === TODAY) {
+ setStart(earliestDate)
+ }
+ }, [earliestDate, start, end])
+
+ const applyQuickRange = (range: QuickRangeConfig) => {
+ if (range.unit === 'all') {
+ applyAllRange()
+ return
+ }
+ applyRange(quickRangeMonths(range))
+ }
+
+ const saveQuickRanges = (next: QuickRangeConfig[]) => {
+ const normalized = normalizeQuickRanges(next)
+ storage.strategyBacktestQuickRanges.set(normalized)
+ return normalized
+ }
+
+ const updateQuickRange = (id: string, patch: Partial>) => {
+ setQuickRanges(prev => {
+ const current = prev.find(range => range.id === id)
+ if (patch.enabled === false && current?.enabled && prev.filter(range => range.enabled).length <= 1) return prev
+ return saveQuickRanges(prev.map(range => range.id === id
+ ? normalizeQuickRange({ ...range, ...patch }, range)
+ : range
+ ))
+ })
+ }
+
+ const visibleQuickRanges = quickRanges.filter(range => range.enabled)
+ const matchedQuickRange = visibleQuickRanges.find(range => range.unit === 'all'
+ ? end === TODAY && (start === earliestDate || start === '')
+ : end === TODAY && start === monthsAgo(quickRangeMonths(range))
+ )
+ const rangeKey = matchedQuickRange?.id ?? 'custom'
+ const rangeTitle = matchedQuickRange ? quickRangeTitle(matchedQuickRange) : '自定义区间'
+ const rangeButtonCls = (key: string) => `rounded-btn px-2 py-1 text-[11px] font-medium transition-colors ${rangeKey === key
+ ? 'bg-accent/15 text-accent'
+ : 'text-muted hover:bg-elevated/70 hover:text-secondary'
+ }`
+
+ const sortedTrades = useMemo(() => {
+ return [...(result?.trades ?? [])].sort((a, b) => {
+ const exitCmp = String(b.exit_date).localeCompare(String(a.exit_date))
+ if (exitCmp !== 0) return exitCmp
+ return String(b.entry_date).localeCompare(String(a.entry_date))
+ })
+ }, [result?.trades])
+
+ const dailyTradeRows = useMemo(() => {
+ const rows = new Map>()
+ const ensure = (date: string) => {
+ if (!rows.has(date)) {
+ rows.set(date, { date, buys: [], sells: [], buyValue: 0, sellValue: 0, realizedPnl: 0 })
+ }
+ return rows.get(date)!
+ }
+
+ for (const t of result?.trades ?? []) {
+ const entryDate = String(t.entry_date).slice(0, 10)
+ const exitDate = String(t.exit_date).slice(0, 10)
+ const buyRow = ensure(entryDate)
+ buyRow.buys.push(t)
+ buyRow.buyValue += Number(t.entry_value ?? 0)
+
+ const sellRow = ensure(exitDate)
+ sellRow.sells.push(t)
+ sellRow.sellValue += Number(t.exit_value ?? 0)
+ sellRow.realizedPnl += Number(t.pnl_amount ?? 0)
+ }
+
+ let cumulativePnl = 0
+ return [...rows.values()]
+ .sort((a, b) => a.date.localeCompare(b.date))
+ .map(row => {
+ cumulativePnl += row.realizedPnl
+ return { ...row, cumulativePnl }
+ })
+ .reverse()
+ }, [result?.trades])
+
+ const tradePageCount = sortedTrades.length
+ ? Math.ceil(sortedTrades.length / tradePageSize)
+ : 0
+ const dailyPageSize = 10
+ const dailyPageCount = dailyTradeRows.length
+ ? Math.ceil(dailyTradeRows.length / dailyPageSize)
+ : 0
+ const safeDailyPage = Math.min(dailyPage, Math.max(dailyPageCount - 1, 0))
+ const dailyStart = safeDailyPage * dailyPageSize
+ const visibleDailyRows = dailyTradeRows.slice(dailyStart, dailyStart + dailyPageSize)
+ const dailyEnd = Math.min(dailyStart + visibleDailyRows.length, dailyTradeRows.length)
+ const safeTradePage = Math.min(tradePage, Math.max(tradePageCount - 1, 0))
+ const tradeStart = safeTradePage * tradePageSize
+ const visibleTrades = sortedTrades.slice(tradeStart, tradeStart + tradePageSize)
+ const tradeEnd = Math.min(tradeStart + visibleTrades.length, sortedTrades.length)
+ const symbolNames = useMemo(() => {
+ const names: Record = {}
+ result?.trades.forEach(t => {
+ if (t.name) names[t.symbol] = t.name
+ })
+ return names
+ }, [result?.trades])
+
+ const detail = strategyDetail.data
+ const basicFilter = (overrides.basic_filter ?? {}) as Record
+ const entrySignals = (overrides.entry_signals ?? []) as string[]
+ const exitSignals = (overrides.exit_signals ?? []) as string[]
+
+ // 自定义信号:合并到买卖触发器选项中(列名带 csg_ 前缀)
+ const customSignalsQuery = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
+ const customSignalOptions = (customSignalsQuery.data?.signals ?? [])
+ .filter(s => s.enabled && s.kind !== (undefined as any)) // 启用的
+ const CUSTOM_LABELS: Record = {}
+ for (const cs of customSignalOptions) {
+ CUSTOM_LABELS[`csg_${cs.id}`] = cs.name
+ }
+ const scoring = useMemo(() => (overrides.scoring ?? {}) as Record, [overrides.scoring])
+ const scoreMinValue = overrides.score_min == null ? '' : String(overrides.score_min)
+ const scoreMaxValue = overrides.score_max == null ? '' : String(overrides.score_max)
+ const stopLossPct = overrides.stop_loss == null ? '' : String(Math.abs(Number(overrides.stop_loss)) * 100)
+ const trailingStopPct = overrides.trailing_stop == null ? '' : String(Math.abs(Number(overrides.trailing_stop)) * 100)
+ const trailingTakeProfitActivatePct = overrides.trailing_take_profit_activate == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_activate)) * 100)
+ const trailingTakeProfitDrawdownPct = overrides.trailing_take_profit_drawdown == null ? '' : String(Math.abs(Number(overrides.trailing_take_profit_drawdown)) * 100)
+ const maxHoldDaysValue = overrides.max_hold_days == null ? '' : String(overrides.max_hold_days)
+ const targetPositionPct = Number(maxPositions) > 0 ? Number(maxExposure) / Number(maxPositions) : 0
+
+ useEffect(() => {
+ if (!editingScoring) setScoringDraft(scoringToPct(scoring))
+ }, [scoring, editingScoring])
+
+ const updateOverride = (key: string, value: any) => {
+ setOverrides(prev => ({ ...prev, [key]: value }))
+ }
+ const updateBasicFilter = (key: string, value: any) => {
+ updateOverride('basic_filter', { ...basicFilter, [key]: value })
+ }
+ const toggleSignal = (key: 'entry_signals' | 'exit_signals', sig: string) => {
+ const list = key === 'entry_signals' ? entrySignals : exitSignals
+ const next = list.includes(sig) ? list.filter(x => x !== sig) : [...list, sig]
+ updateOverride(key, next)
+ }
+ const startScoringEdit = () => {
+ setScoringDraft(scoringToPct(scoring))
+ setEditingScoring(true)
+ }
+ const cancelScoringEdit = () => {
+ setScoringDraft(scoringToPct(scoring))
+ setEditingScoring(false)
+ }
+ const saveScoringDraft = () => {
+ updateOverride('scoring', normalizePctWeights(scoringDraft))
+ setEditingScoring(false)
+ }
+ const scoreFilterSummary = scoreMinValue !== '' && scoreMaxValue !== ''
+ ? `评分 ${scoreMinValue}~${scoreMaxValue}`
+ : scoreMinValue !== ''
+ ? `评分 ≥${scoreMinValue}`
+ : scoreMaxValue !== ''
+ ? `评分 ≤${scoreMaxValue}`
+ : '评分不过滤'
+ const advancedSummary = detail
+ ? [
+ detail.params.length > 0 ? `参数 ${detail.params.length}` : '无策略参数',
+ basicFilter.enabled !== false ? '过滤开' : '过滤关',
+ `买点 ${entrySignals.length}`,
+ `卖点 ${exitSignals.length}`,
+ scoreFilterSummary,
+ stopLossPct !== '' ? `止损 ${stopLossPct}%` : '止损未设',
+ trailingStopPct !== '' ? `移损 ${trailingStopPct}%` : '移损未设',
+ trailingTakeProfitActivatePct !== '' && trailingTakeProfitDrawdownPct !== '' ? `回撤 ${trailingTakeProfitActivatePct}-${trailingTakeProfitDrawdownPct}点` : '回撤未设',
+ maxHoldDaysValue !== '' ? `最长 ${maxHoldDaysValue}天` : '不限持仓',
+ ].join(' · ')
+ : '选择策略后可调整参数 / 过滤 / 买卖触发器 / 评分 / 风控'
+ const selectedStrategyName = detail?.name ?? strategyList.find(st => st.id === selectedStrategy)?.name ?? '未选择策略'
+ const selectedStrategySource = detail?.source ?? strategyList.find(st => st.id === selectedStrategy)?.source
+ const stockPoolCount = symbols.split(',').map(s => s.trim()).filter(Boolean).length
+ const stockPoolSummary = stockPoolCount > 0 ? `股票池 已限定 ${stockPoolCount} 只` : '股票池 全市场'
+ const resultStartDate = result?.config?.start ?? result?.equity_curve?.[0]?.date ?? start
+ const resultEndDate = result?.config?.end ?? result?.equity_curve?.[result.equity_curve.length - 1]?.date ?? end
+ const resultTradeDays = result?.equity_curve?.length ?? 0
+ const executionStats = (result?.stats?.execution ?? {}) as Record
+ const executionSummary = [
+ ['buy_no_slot', '满仓未买'],
+ ['buy_exposure', '仓位上限'],
+ ['buy_score_filter', '评分过滤'],
+ ['buy_limit_up', '涨停未买'],
+ ['buy_suspended', '停牌未买'],
+ ['sell_limit_down', '跌停阻塞'],
+ ['sell_suspended', '停牌阻塞'],
+ ['pending_exit', '待卖阻塞'],
+ ]
+ .map(([key, label]) => ({ key, label, value: Number(executionStats[key] ?? 0) }))
+ .filter(item => item.value > 0)
+
+ return (
+
+ {/* 配置面板 */}
+
+
+
+
选择策略
+ {/* 高颗粒回测(分钟K)— 开发中占位 */}
+
+
+ {
+ if (isFreeTier) return
+ // 功能开发中,暂不实际启用
+ setHighGranularity(v => !v)
+ }}
+ disabled={isFreeTier}
+ title={isFreeTier
+ ? '高颗粒回测(分钟K精确回测):需 Starter+ 档位'
+ : '高颗粒回测(分钟K精确回测):切换后结合每日分钟K更精确回测。⚠️ 开发中,且会显著影响性能、回测很慢。'
+ }
+ className={`group relative inline-flex h-3.5 w-6 items-center rounded-full shrink-0 transition-colors duration-200 ${
+ isFreeTier ? 'bg-elevated opacity-50 cursor-not-allowed'
+ : highGranularity ? 'bg-amber-500 cursor-pointer'
+ : 'bg-elevated cursor-pointer'
+ }`}
+ >
+
+
+ 分钟K
+ {isFreeTier && (
+ Starter+
+ )}
+
+
+ {/* 高颗粒开启时的警告条 */}
+ {highGranularity && !isFreeTier && (
+
+
+
+ 高颗粒回测(开发中)
+ :将结合每日分钟K进行更精确的回测。
+ ⚠️ 此功能尚未完成,且开启后会显著拖慢回测速度、占用大量资源。
+
+
+ )}
+
+
+ {STRATEGY_GROUPS.map(group => (
+ setStrategyGroup(group.id)}
+ className={`flex-1 rounded-[6px] px-1.5 py-1 text-[10px] font-medium transition-colors ${strategyGroup === group.id
+ ? 'bg-accent/15 text-accent shadow-sm'
+ : 'text-muted hover:bg-elevated/70 hover:text-secondary'
+ }`}
+ >
+ {group.label}
+
+ ))}
+
+
+ {strategies.isLoading && (
+ 加载中…
+ )}
+ {!strategies.isLoading && filteredStrategyList.length === 0 && (
+ 当前分组暂无策略
+ )}
+ {filteredStrategyList.map(st => (
+ setSelectedStrategy(st.id)}
+ className={`px-2 py-1 rounded-btn text-[11px] border transition-all duration-150 ease-smooth cursor-pointer
+ ${selectedStrategy === st.id
+ ? 'border-accent/50 bg-accent/10 text-accent shadow-[0_0_10px_rgba(59,130,246,0.1)]'
+ : 'border-border bg-base text-secondary hover:border-accent/40'
+ }`}
+ >
+ {st.name}
+ {st.source && st.source !== 'builtin' && (
+
+ {SRC_MAP[st.source] ?? ''}
+
+ )}
+
+ ))}
+
+
+
+
+ {selectedStrategy && strategyDetail.isLoading && (
+ 加载策略配置…
+ )}
+
+ detail && setSettingsOpen(true)}
+ disabled={!detail || strategyDetail.isLoading}
+ className="group w-full rounded-btn border border-border bg-surface px-3 py-2.5 text-left transition-colors hover:border-accent/40 hover:bg-elevated/70 disabled:cursor-not-allowed disabled:opacity-55"
+ >
+
+
+ 策略设置
+ 编辑
+
+
+ {selectedStrategyName}
+ {selectedStrategySource && (
+
+ {SRC_MAP[selectedStrategySource] ?? selectedStrategySource}
+
+ )}
+
+ {stockPoolSummary}
+ {advancedSummary}
+
+
+
+
+
回测区间
+
+ {rangeTitle}
+
+
+
+
+
+
+
+ {visibleQuickRanges.map(range => (
+ applyQuickRange(range)}
+ className={`${rangeButtonCls(range.id)} flex-1`}
+ >
+ {quickRangeLabel(range)}
+
+ ))}
+
+
setRangeSettingsOpen(v => !v)}
+ title="设置快捷区间"
+ aria-label="设置快捷区间"
+ className={`shrink-0 rounded-btn border px-2 py-1.5 transition-colors ${rangeSettingsOpen
+ ? 'border-accent/40 bg-accent/10 text-accent'
+ : 'border-border bg-base text-secondary hover:border-accent/40 hover:text-accent'
+ }`}
+ >
+
+
+
+
+ {rangeSettingsOpen && (
+
+
+ 快捷区间
+ 月 1-120 / 年 1-10
+
+
+
+ )}
+
+
+
+
成交口径
+
setMatching(e.target.value as any)} className={INPUT_CLS}>
+ 收盘确认 → 次日开盘成交(推荐)
+ 收盘确认 → 信号日收盘成交(偏理想,仅作对照)
+
+
买卖点由策略触发器决定;这里只决定日线信号确认后按哪个价格成交。
+
+
+ {simMode === 'position' && (
+
+ )}
+ {simMode === 'position' && (
+
+ 单票目标约 {Number.isFinite(targetPositionPct) ? targetPositionPct.toFixed(1) : '—'}%。最大总仓位控制资金投入;剩余现金不是新增持仓名额,只有实际卖出成功才释放持仓数。
+
+ )}
+ {simMode === 'full' && (
+
+ 全量模拟 :每日将策略选出的全部候选独立买入,不受资金/最大持仓数限制;每一笔仍按策略卖点、止损、移动止盈/止损和最长持仓执行,用于评估策略本身的选股 + 交易规则质量。
+
+ )}
+
+ {isPending ? (
+
+
+ 停止回测
+
+ ) : (
+
+
+
+
+ 运行回测
+
+ )}
+
+
+ {/* 结果面板 */}
+
+ {/* 模式切换: 仓位模拟 / 全量模拟 */}
+
+
+ {([['position', '仓位模拟'], ['full', '全量模拟']] as const).map(([val, label]) => (
+
setSimMode(val)}
+ className={`inline-flex items-center gap-1.5 rounded-[5px] px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
+ simMode === val
+ ? 'bg-accent text-white shadow-sm'
+ : 'text-secondary hover:bg-elevated hover:text-foreground'
+ }`}
+ title={val === 'position' ? '受仓位/资金约束的真实账户模拟' : '全部候选独立执行,不受资金和持仓数量约束'}
+ >
+ {val === 'position' ? : }
+ {label}
+
+ ))}
+
+ {simMode === 'full' && (
+ maxHoldDaysValue !== '' ? (
+
+ 策略最长 {maxHoldDaysValue} 天
+
+ ) : (
+
+
兜底上限
+
+ {(['1', '5', '10', '20'] as const).map(d => (
+ setHoldingDays(d)}
+ className={`px-2 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
+ holdingDays === d
+ ? 'bg-accent/10 text-accent'
+ : 'text-muted hover:text-secondary hover:bg-elevated'
+ }`}
+ >
+ {d}天
+
+ ))}
+
+
+ )
+ )}
+
+
+ {result?.error && (
+
+ {result.error}
+
+ )}
+
+ {backtestTask?.error && (
+
+ {backtestTask.error}
+
+ )}
+
+ {!result && !isPending && (
+
+ )}
+
+ {isPending && (
+
+
+
+
+
+
+
+
+ {backtestTask?.progress
+ ? `回测中 · 第 ${backtestTask.progress.day}/${backtestTask.progress.total} 天 (${backtestTask.progress.date})`
+ : '正在重新计算回测…'}
+
+
+ {result ? '当前展示上次结果,完成后自动替换' : '正在加载回测数据…'}
+
+
+ {backtestTask?.progress && (
+
+ {((backtestTask.progress.day / backtestTask.progress.total) * 100).toFixed(0)}%
+
+ )}
+
+
+ 停止
+
+
+ {backtestTask?.progress && (
+
+ )}
+
+ )}
+
+ {/* 旧全量模拟结果: 固定前瞻收益统计 (兼容历史缓存结果) */}
+ {result && !result.error && result.stats && result.stats.mode === 'full' && result.stats.full_kind !== 'candidate_execution' && (
+
+
+ {result.strategy_info?.name ?? '策略'}
+ 全量模拟
+ 持有 {result.config?.holding_days ?? 5} 天
+
+ {String(result.config?.start).slice(0,10)} ~ {String(result.config?.end).slice(0,10)}
+
+
+
+ {/* 统计卡片 */}
+
+
+
+
+
+
+
+
+
+
+
+
+ 候选样本 {result.stats.n_candidates ?? 0} (标的×信号日)
+ 信号天数 {result.stats.n_days ?? 0}
+ 日均候选 {result.stats.avg_daily_candidates ?? 0}
+ 最佳 {fmtPct(result.stats.best)}
+ 最差 {fmtPct(result.stats.worst)}
+ 基准(上证) {fmtPct(result.stats.benchmark_return)}
+
+
+ {/* 累计超额曲线 (复用 StrategyNavChart) */}
+ {result.equity_curve.length > 1 && (
+
+ )}
+
+ {/* 收益分布直方图 */}
+ {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
+
+
+ 候选标的收益分布(持有 {result.config?.holding_days ?? 5} 天)
+ 红=正收益 · 绿=负收益
+
+
+
+ )}
+
+ run_id: {result.run_id}
+
+ )}
+
+ {result && !result.error && result.stats && !result.stats.error && (result.stats.mode !== 'full' || result.stats.full_kind === 'candidate_execution') && (
+
+ {/* 策略信息 */}
+ {result.strategy_info && (
+
+
+ {result.strategy_info.name}
+ {result.stats.full_kind === 'candidate_execution' && (
+ 全量独立执行
+ )}
+ {result.strategy_info.source && (
+
+ {SRC_MAP[result.strategy_info.source] ?? ''}
+
+ )}
+
+ {result.strategy_info.stop_loss != null && (
+
止损 {fmtPct(result.strategy_info.stop_loss)}
+ )}
+ {result.strategy_info.trailing_stop != null && (
+
移损 {fmtPct(result.strategy_info.trailing_stop)}
+ )}
+ {result.strategy_info.trailing_take_profit_activate != null && result.strategy_info.trailing_take_profit_drawdown != null && (
+
回撤 {fmtPct(result.strategy_info.trailing_take_profit_activate)}-{fmtPct(result.strategy_info.trailing_take_profit_drawdown)}
+ )}
+ {result.strategy_info.max_hold_days != null && (
+
最长 {result.strategy_info.max_hold_days} 天
+ )}
+ {resultTradeDays > 0 && (
+
+ {String(resultStartDate).slice(0, 10)} ~ {String(resultEndDate).slice(0, 10)}
+ {resultTradeDays} 天
+
+ )}
+ {result.elapsed_ms > 0 && (
+
0 ? '' : 'ml-auto'}`}>
+
+ 总耗时
+ {fmtDuration(result.elapsed_ms)}
+
+ )}
+
+ )}
+
+ {/* 统计卡片 */}
+
+
+
+
+
+
+ } value={pick('sharpe') != null ? Number(pick('sharpe')).toFixed(2) : '—'} />
+
+
+
+ {result.stats.full_kind === 'candidate_execution' ? (
+
+ ) : (
+
+ )}
+
+
+
+ {executionSummary.length > 0 && (
+
+ 成交约束:
+ {executionSummary.map((item, index) => (
+
+ {index > 0 ? '· ' : ''}{item.label} {item.value} 次
+
+ ))}
+
+ )}
+
+ {/* 净值曲线 */}
+ {result.equity_curve.length > 0 && (
+
+
+
+ )}
+
+ {Array.isArray(result.stats.return_distribution) && result.stats.return_distribution.length > 0 && (
+
+
+ 独立候选交易收益分布
+ 红=正收益 · 绿=负收益
+
+
+
+ )}
+
+ {/* Tab: 按日期 / 交易明细 / 选股分析 */}
+ {(result.trades.length > 0 || result.per_symbol_stats.length > 0) && (
+
+
+ {(['daily', 'trades', 'picks'] as const).map(t => (
+ setResultTab(t)}
+ className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors cursor-pointer ${
+ resultTab === t
+ ? 'border-accent text-accent'
+ : 'border-transparent text-secondary hover:text-foreground'
+ }`}
+ >
+ {t === 'daily'
+ ? `每日交易 (${dailyTradeRows.length})`
+ : t === 'trades'
+ ? `交易明细 (${sortedTrades.length})`
+ : `选股分析 (${result.per_symbol_stats.length})`}
+
+ ))}
+
+
+ {resultTab === 'daily' && (
+
+
+
+
+
+ 日期
+ 买入
+ 卖出
+ 当日收益
+ 累计收益
+
+
+
+ {visibleDailyRows.map(row => (
+
+
+ {row.date}
+
+ 买 {row.buys.length} / 卖 {row.sells.length}
+
+
+
+ {row.buys.length === 0 ? (
+ —
+ ) : (
+
+ {row.buys.map((t, i) => (
+ setSelectedTrade(t)} />
+ ))}
+
+ )}
+
+
+ {row.sells.length === 0 ? (
+ —
+ ) : (
+
+ {row.sells.map((t, i) => (
+ setSelectedTrade(t)} />
+ ))}
+
+ )}
+
+
+ {fmtSignedMoney(row.realizedPnl)}
+
+
+ {fmtSignedMoney(row.cumulativePnl)}
+
+
+ ))}
+
+
+
+ {dailyTradeRows.length > 0 && (
+
+
+ 显示 {dailyStart + 1}-{dailyEnd} 天 / 共 {dailyTradeRows.length} 天,每页 10 天
+
+
+ setDailyPage(p => Math.max(0, p - 1))}
+ disabled={safeDailyPage <= 0}
+ className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
+ >
+ 上一页
+
+
+ {safeDailyPage + 1} / {dailyPageCount}
+
+ setDailyPage(p => Math.min(dailyPageCount - 1, p + 1))}
+ disabled={safeDailyPage >= dailyPageCount - 1}
+ className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
+ >
+ 下一页
+
+
+
+ )}
+
+ )}
+
+ {resultTab === 'trades' && (
+
+
+
+
+ 标的
+ 买入
+ 卖出
+ 仓位 / 手数
+ 单票盈亏
+ 持仓
+ 原因
+
+
+
+ {visibleTrades.map((t: StrategyBacktestTrade, i: number) => (
+
+
+
+ {t.name || t.symbol}
+
+ {t.symbol}
+
+
+
+
+
+
+
+
+ {fmtPct(t.position_pct, 2)}
+
+ {fmtLots(t.lots)} 手
+ {fmtShares(t.shares)} 股
+
+
+
+ {fmtSignedMoney(t.pnl_amount)}
+ {fmtPct(t.pnl_pct)}
+
+
+ {t.duration} 天
+ {!!t.blocked_exit_days && 阻塞 {t.blocked_exit_days} 天
}
+
+
+
+ ))}
+
+
+ {sortedTrades.length > 0 && (
+
+
+ 显示 {tradeStart + 1}-{tradeEnd} 条 / 共 {sortedTrades.length} 条
+
+
+
+ 每页
+ {
+ setTradePageSize(Number(e.target.value))
+ setTradePage(0)
+ }}
+ className="rounded-btn border border-border bg-surface px-2 py-1 text-xs text-secondary focus:outline-none focus:border-accent"
+ >
+ {TRADE_PAGE_SIZE_OPTIONS.map(size => (
+ {size}
+ ))}
+
+ 条
+
+ setTradePage(p => Math.max(0, p - 1))}
+ disabled={safeTradePage <= 0}
+ className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
+ >
+ 上一页
+
+
+ {safeTradePage + 1} / {tradePageCount}
+
+ setTradePage(p => Math.min(tradePageCount - 1, p + 1))}
+ disabled={safeTradePage >= tradePageCount - 1}
+ className="rounded-btn border border-border bg-surface px-2.5 py-1 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent disabled:cursor-not-allowed disabled:opacity-45"
+ >
+ 下一页
+
+
+
+ )}
+
+ )}
+
+ {resultTab === 'picks' && (
+
+
+
+ 标的
+ 选股次数
+ 总收益
+ 胜率
+ 最佳
+ 最差
+
+
+
+ {result.per_symbol_stats.map((r) => (
+
+
+
+ {symbolNames[r.symbol] || r.symbol}
+
+ {r.symbol}
+
+ {r.n_trades}
+
+ {fmtPct(r.total_return)}
+
+ {fmtPct(r.win_rate)}
+ {fmtPct(r.best)}
+ {fmtPct(r.worst)}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+ run_id: {result.run_id}
+
+
+ )}
+
+
+ {settingsOpen && detail && (
+ <>
+
setSettingsOpen(false)}
+ className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[1px]"
+ />
+
+
+
+
+
+ 高级策略设置
+
+ {SRC_MAP[detail.source] ?? ''}
+
+
+
{detail.name}
+
{advancedSummary}
+
+
setSettingsOpen(false)}
+ className="rounded-btn border border-border bg-surface p-1.5 text-muted transition-colors hover:border-accent/40 hover:text-foreground"
+ >
+
+
+
+
+ {ADVANCED_TABS.map(tab => (
+ setSettingsTab(tab.id)}
+ className={`shrink-0 rounded-btn border px-3 py-1.5 text-xs transition-colors ${settingsTab === tab.id
+ ? 'border-accent/50 bg-accent/10 text-accent'
+ : 'border-border bg-surface text-secondary hover:border-accent/40 hover:text-foreground'
+ }`}
+ >
+ {tab.label}
+
+ ))}
+
+
+
+
+
+
触发 / 成交 / 仓位关系
+
触发器决定什么时候产生买卖信号;评分只在多个买点同时出现时排序。
+
默认按日线收盘确认,次日开盘成交;信号日收盘成交为偏理想对照口径。
+
最大持仓数控制同时持股数量,最大总仓位控制资金投入比例;剩余现金不等于可新增持仓名额。
+
+
+ {settingsTab === 'range' && (
+
留空 = 全市场}>
+
+ 默认全市场回测,由基础过滤、策略条件和买卖触发器筛选;需要单票调试或自选池回测时再限定股票池。
+
+ )}
+
+ {settingsTab === 'params' && (
+
+ {detail.params.length > 0 ? (
+
+ {detail.params.map(param => (
+ setStrategyParams(prev => ({ ...prev, [param.id]: value }))}
+ />
+ ))}
+
+ ) : (
+ 当前策略没有可调参数。
+ )}
+
+ )}
+
+ {settingsTab === 'filter' && (
+
+
+ updateBasicFilter('enabled', e.target.checked)}
+ />
+ 启用基础过滤
+
+
+ {BASIC_FILTER_FIELDS.map(field => {
+ const scale = field.scale ?? 1
+ const value = basicFilter[field.key] == null ? '' : Number(basicFilter[field.key]) / scale
+ return (
+
+ {field.label}({field.unit})
+ {
+ const n = numOrNull(e.target.value)
+ updateBasicFilter(field.key, n == null ? null : n * scale)
+ }}
+ className={INPUT_CLS}
+ />
+
+ )
+ })}
+
+
+ updateBasicFilter('exclude_st', e.target.checked)}
+ />
+ 排除 ST / 退市
+
+
+ {BOARD_OPTIONS.map(board => {
+ const boards = Array.isArray(basicFilter.boards) ? basicFilter.boards : []
+ const checked = boards.includes(board)
+ return (
+ updateBasicFilter('boards', checked ? boards.filter((b: string) => b !== board) : [...boards, board])}
+ className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${checked ? 'border-accent/50 bg-accent/10 text-accent' : 'border-border bg-base text-muted hover:border-accent/40'}`}
+ >
+ {board}
+
+ )
+ })}
+
+
+ )}
+
+ {settingsTab === 'entry' && (
+
+
+ {SIGNAL_OPTIONS.map(sig => (
+ toggleSignal('entry_signals', sig)}
+ className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${entrySignals.includes(sig) ? 'border-accent/50 bg-accent/10 text-accent' : 'border-border bg-base text-muted hover:border-accent/40'}`}
+ >
+ {SIGNAL_LABELS[sig]}
+
+ ))}
+ {customSignalOptions.filter(cs => cs.kind === 'entry' || cs.kind === 'both').map(cs => {
+ const id = `csg_${cs.id}`
+ return (
+ toggleSignal('entry_signals', id)}
+ title="自定义信号"
+ className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${entrySignals.includes(id) ? 'border-accent/50 bg-accent/10 text-accent' : 'border-amber-400/30 bg-amber-400/5 text-secondary hover:border-amber-400/50 hover:text-amber-400'}`}
+ >
+ {CUSTOM_LABELS[id]}
+
+ )
+ })}
+
+
+ )}
+
+ {settingsTab === 'exit' && (
+
+
+ {SIGNAL_OPTIONS.map(sig => (
+ toggleSignal('exit_signals', sig)}
+ className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${exitSignals.includes(sig) ? 'border-warning/50 bg-warning/10 text-warning' : 'border-border bg-base text-muted hover:border-warning/40'}`}
+ >
+ {SIGNAL_LABELS[sig]}
+
+ ))}
+ {customSignalOptions.filter(cs => cs.kind === 'exit' || cs.kind === 'both').map(cs => {
+ const id = `csg_${cs.id}`
+ return (
+ toggleSignal('exit_signals', id)}
+ title="自定义信号"
+ className={`rounded-btn border px-2.5 py-1.5 text-[11px] transition-colors ${exitSignals.includes(id) ? 'border-warning/50 bg-warning/10 text-warning' : 'border-amber-400/30 bg-amber-400/5 text-secondary hover:border-amber-400/50 hover:text-amber-400'}`}
+ >
+ {CUSTOM_LABELS[id]}
+
+ )
+ })}
+
+
+ )}
+
+ {settingsTab === 'scoring' && (
+
+ {Object.entries(scoring).length > 0 ? (() => {
+ const visibleWeights = editingScoring ? scoringDraft : scoringToPct(scoring)
+ const total = Object.values(visibleWeights).reduce((a, b) => a + b, 0)
+ return (
+
+
+ {Object.keys(scoring).map(key => (
+ setScoringDraft(prev => ({ ...prev, [key]: Math.max(0, value) }))}
+ />
+ ))}
+
+
+
+ 总和 {editingScoring ? total : 100}
+ 保存时自动归一化计算
+
+
+ {editingScoring && (
+
+ 取消
+
+ )}
+
+ {editingScoring ? '保存归权' : '调整权重'}
+
+
+
+
+ )
+ })() : (
+ 当前策略没有评分权重。
+ )}
+
+
+ 评分过滤
+ 留空 = 不过滤;命中范围后按评分从高到低买入
+
+
+
+ 最小评分
+ {
+ const n = numOrNull(e.target.value)
+ updateOverride('score_min', n == null ? null : clamp(n, 0, 100))
+ }}
+ className={INPUT_CLS}
+ />
+
+
+ 最大评分
+ {
+ const n = numOrNull(e.target.value)
+ updateOverride('score_max', n == null ? null : clamp(n, 0, 100))
+ }}
+ className={INPUT_CLS}
+ />
+
+
+
例如最小值 71 表示只把评分 ≥ 71 的股票放入下一交易日买入预选池。
+
+
+ )}
+
+ {settingsTab === 'risk' && (
+
+
+
+ 止损(%)
+ {
+ const n = numOrNull(e.target.value)
+ updateOverride('stop_loss', n == null ? null : -Math.abs(n) / 100)
+ }}
+ className={INPUT_CLS}
+ />
+
+
+ 移动止损(%)
+ {
+ const n = numOrNull(e.target.value)
+ updateOverride('trailing_stop', n == null ? null : -clamp(Math.abs(n), 0.5, 50) / 100)
+ }}
+ className={INPUT_CLS}
+ />
+
+
+ 回撤止盈启动(%)
+ {
+ const n = numOrNull(e.target.value)
+ const next = n == null ? null : clamp(Math.abs(n), 1, 200) / 100
+ updateOverride('trailing_take_profit_activate', next)
+ const drawdown = numOrNull(trailingTakeProfitDrawdownPct)
+ if (next != null && drawdown != null && drawdown / 100 > next) {
+ updateOverride('trailing_take_profit_drawdown', next)
+ }
+ }}
+ className={INPUT_CLS}
+ />
+
+
+ 回撤止盈回撤(点)
+ {
+ const n = numOrNull(e.target.value)
+ const activate = numOrNull(trailingTakeProfitActivatePct)
+ const maxValue = activate == null ? 50 : Math.min(50, Math.abs(activate))
+ updateOverride('trailing_take_profit_drawdown', n == null ? null : clamp(Math.abs(n), 0.5, maxValue) / 100)
+ }}
+ className={INPUT_CLS}
+ />
+
+
+ 最长持仓(天)
+ {
+ const n = numOrNull(e.target.value)
+ updateOverride('max_hold_days', n == null ? null : Math.max(1, Math.round(n)))
+ }}
+ className={INPUT_CLS}
+ />
+
+
+
+ )}
+
+
+
+ resetConfigFromDetail(detail)}
+ className="rounded-btn border border-border bg-surface px-3 py-1.5 text-xs text-secondary transition-colors hover:border-accent/40 hover:text-accent"
+ >
+ 恢复默认
+
+ setSettingsOpen(false)}
+ className="rounded-btn bg-accent px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent/90"
+ >
+ 完成
+
+
+
+ >
+ )}
+
+ setSelectedTrade(null)} />
+
+ )
+}
diff --git a/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx b/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx
new file mode 100644
index 0000000..98e618b
--- /dev/null
+++ b/frontend/src/pages/backtest/charts/FactorGroupNavChart.tsx
@@ -0,0 +1,124 @@
+import { useMemo } from 'react'
+import { useECharts } from './useECharts'
+import type { FactorBacktestResult } from '@/lib/api'
+
+const GROUP_COLORS = [
+ '#6366f1', // Q1 indigo
+ '#8b5cf6', // Q2 violet
+ '#f59e0b', // Q3 amber
+ '#f97316', // Q4 orange
+ '#ef4444', // Q5 red
+ '#ec4899', // Q6
+ '#14b8a6', // Q7
+ '#06b6d4', // Q8
+ '#84cc16', // Q9
+ '#a855f7', // Q10
+]
+
+interface Props {
+ result: FactorBacktestResult
+}
+
+export function FactorGroupNavChart({ result }: Props) {
+ const option = useMemo(() => {
+ if (!result.group_nav.length) return null
+
+ const dates = result.group_nav.map(r => (r.date as string).slice(0, 10))
+ const groupCols = Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
+
+ // 多空净值
+ const lsNav = result.long_short_nav
+ const hasLS = lsNav && lsNav.length > 0
+
+ const series = groupCols.map((col, i) => ({
+ name: col,
+ type: 'line',
+ data: result.group_nav.map(r => r[col]),
+ symbol: 'none',
+ lineStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length], width: 1.5 } as any,
+ itemStyle: { color: GROUP_COLORS[i % GROUP_COLORS.length] } as any,
+ }))
+
+ if (hasLS) {
+ series.push({
+ name: '多空',
+ type: 'line',
+ data: lsNav.map(r => r.value),
+ symbol: 'none',
+ lineStyle: { color: '#fbbf24', width: 2, type: 'dashed' },
+ itemStyle: { color: '#fbbf24' },
+ })
+ }
+
+ return {
+ animation: false,
+ legend: {
+ show: false,
+ },
+ grid: { left: 56, right: 16, top: 12, bottom: 28 },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(15,23,42,0.95)',
+ borderColor: 'rgba(148,163,184,0.2)',
+ textStyle: { color: '#e2e8f0', fontSize: 12 },
+ formatter: (params: any) => {
+ const date = params[0]?.axisValue ?? ''
+ let html = `${date}
`
+ for (const p of params) {
+ if (p.value == null) continue
+ html += `
+
+
+ ${p.seriesName}
+
+ ${(p.value as number).toFixed(4)}
+
`
+ }
+ return html
+ },
+ },
+ xAxis: {
+ type: 'category',
+ data: dates,
+ axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
+ axisLine: { lineStyle: { color: '#334155' } },
+ axisTick: { show: false },
+ },
+ yAxis: {
+ type: 'value',
+ scale: true,
+ axisLabel: { color: '#64748b', fontSize: 10 },
+ splitLine: { lineStyle: { color: '#1e293b' } },
+ axisLine: { show: false },
+ },
+ series,
+ } as any
+ }, [result.group_nav, result.long_short_nav, result.run_id])
+
+ const chartRef = useECharts(option, [result.run_id])
+
+ // 图例
+ const groupCols = result.group_nav.length > 0
+ ? Object.keys(result.group_nav[0]).filter(k => k !== 'date').sort()
+ : []
+
+ return (
+
+
+ {groupCols.map((col, i) => (
+
+
+ {col}
+
+ ))}
+ {result.long_short_nav?.length > 0 && (
+
+
+ 多空
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/backtest/charts/FactorICChart.tsx b/frontend/src/pages/backtest/charts/FactorICChart.tsx
new file mode 100644
index 0000000..c66b161
--- /dev/null
+++ b/frontend/src/pages/backtest/charts/FactorICChart.tsx
@@ -0,0 +1,88 @@
+import { useMemo } from 'react'
+import { useECharts } from './useECharts'
+import type { FactorBacktestResult } from '@/lib/api'
+
+interface Props {
+ result: FactorBacktestResult
+}
+
+export function FactorICChart({ result }: Props) {
+ const option = useMemo(() => {
+ if (!result.ic_series.length) return null
+
+ const dates = result.ic_series.map(r => r.date.slice(0, 10))
+ const values = result.ic_series.map(r => r.ic)
+
+ // 12期移动平均
+ const maWindow = 12
+ const ma: (number | null)[] = values.map((_, i) => {
+ if (i < maWindow - 1) return null
+ const slice = values.slice(i - maWindow + 1, i + 1)
+ return slice.reduce((a, b) => a + b, 0) / slice.length
+ })
+
+ return {
+ animation: false,
+ grid: { left: 50, right: 16, top: 16, bottom: 28 },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(15,23,42,0.95)',
+ borderColor: 'rgba(148,163,184,0.2)',
+ textStyle: { color: '#e2e8f0', fontSize: 12 },
+ formatter: (params: any) => {
+ const date = params[0]?.axisValue ?? ''
+ let html = `${date}
`
+ for (const p of params) {
+ if (p.value == null) continue
+ html += `
+ ${p.seriesName}
+ ${(p.value * 100).toFixed(2)}%
+
`
+ }
+ return html
+ },
+ },
+ xAxis: {
+ type: 'category',
+ data: dates,
+ axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
+ axisLine: { lineStyle: { color: '#334155' } },
+ axisTick: { show: false },
+ },
+ yAxis: {
+ type: 'value',
+ axisLabel: { color: '#64748b', fontSize: 10, formatter: (v: number) => `${(v * 100).toFixed(0)}%` },
+ splitLine: { lineStyle: { color: '#1e293b' } },
+ axisLine: { show: false },
+ },
+ series: [
+ {
+ name: 'IC',
+ type: 'bar',
+ data: values.map(v => ({
+ value: v,
+ itemStyle: {
+ color: v >= 0
+ ? 'rgba(240,68,56,0.6)'
+ : 'rgba(18,183,106,0.6)',
+ },
+ })),
+ barMaxWidth: 6,
+ },
+ {
+ name: `MA${maWindow}`,
+ type: 'line',
+ data: ma,
+ smooth: true,
+ symbol: 'none',
+ lineStyle: { color: '#f59e0b', width: 1.5 },
+ z: 10,
+ },
+ ],
+ } as any
+ }, [result.ic_series])
+
+ const chartRef = useECharts(option, [result.run_id])
+
+ return
+}
diff --git a/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx b/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx
new file mode 100644
index 0000000..382fcb3
--- /dev/null
+++ b/frontend/src/pages/backtest/charts/ReturnDistributionChart.tsx
@@ -0,0 +1,63 @@
+import { useMemo } from 'react'
+import { useECharts } from './useECharts'
+import type { EChartsOption } from 'echarts'
+
+interface DistBin {
+ range: string
+ count: number
+ ratio: number
+}
+
+/**
+ * 收益分布直方图 — 全量模拟专用的候选标的收益分布。
+ * 柱子颜色按收益正负区分(正红负绿),零轴居中。
+ */
+export function ReturnDistributionChart({ distribution }: { distribution: DistBin[] }) {
+ const option = useMemo(() => {
+ const cats = distribution.map(d => d.range)
+ const vals = distribution.map(d => d.count)
+ // 判断每档是正还是负(按 range 字符串首字符 +/~)
+ const colors = distribution.map(d => {
+ const lo = parseFloat(d.range)
+ // 中心档(跨 0) 用中性色
+ if (lo < 0 && parseFloat(d.range.split('~')[1]) > 0) return '#a1a1aa'
+ return lo >= 0 ? '#ef4444' : '#22c55e'
+ })
+
+ return {
+ grid: { left: 48, right: 16, top: 24, bottom: 56 },
+ tooltip: {
+ trigger: 'axis',
+ axisPointer: { type: 'shadow' },
+ formatter: (params: any) => {
+ const p = Array.isArray(params) ? params[0] : params
+ const bin = distribution[p.dataIndex]
+ if (!bin) return ''
+ return `${bin.range} 数量: ${bin.count} 占比: ${(bin.ratio * 100).toFixed(1)}%`
+ },
+ },
+ xAxis: {
+ type: 'category',
+ data: cats,
+ axisLabel: { color: '#a1a1aa', fontSize: 10, rotate: 45, interval: 1 },
+ axisLine: { lineStyle: { color: '#3f3f46' } },
+ },
+ yAxis: {
+ type: 'value',
+ axisLabel: { color: '#a1a1aa', fontSize: 10 },
+ splitLine: { lineStyle: { color: '#27272a' } },
+ },
+ series: [
+ {
+ type: 'bar',
+ data: vals.map((v, i) => ({ value: v, itemStyle: { color: colors[i] } })),
+ barWidth: '90%',
+ },
+ ],
+ }
+ }, [distribution])
+
+ const chartRef = useECharts(option, [distribution])
+
+ return
+}
diff --git a/frontend/src/pages/backtest/charts/StrategyNavChart.tsx b/frontend/src/pages/backtest/charts/StrategyNavChart.tsx
new file mode 100644
index 0000000..a6c808e
--- /dev/null
+++ b/frontend/src/pages/backtest/charts/StrategyNavChart.tsx
@@ -0,0 +1,209 @@
+import { useMemo } from 'react'
+import { useECharts } from './useECharts'
+import type { StrategyBacktestResult } from '@/lib/api'
+
+interface Props {
+ result: StrategyBacktestResult
+}
+
+export function StrategyNavChart({ result }: Props) {
+ const option = useMemo(() => {
+ if (!result.equity_curve.length) return null
+
+ const moneyFmt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
+ const valueFmt = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+ const axisMoneyFmt = (v: number) => {
+ if (Math.abs(v) >= 100_000_000) return `${(v / 100_000_000).toFixed(1)}亿`
+ if (Math.abs(v) >= 10_000) return `${(v / 10_000).toFixed(0)}万`
+ return moneyFmt.format(v)
+ }
+ const dates = result.equity_curve.map(r => r.date.slice(0, 10))
+ const navValues = result.equity_curve.map(r => r.value)
+ const benchmarkByDate = new Map((result.benchmark_curve ?? []).map(r => [r.date.slice(0, 10), r.close ?? r.value]))
+ const benchmarkValues = dates.map(d => benchmarkByDate.get(d) ?? null)
+ const hasBenchmark = benchmarkValues.some(v => v != null)
+ const ddValues = result.drawdown_curve.map(r => r.value * 100)
+
+ return {
+ animation: false,
+ axisPointer: {
+ link: [{ xAxisIndex: 'all' }],
+ label: { backgroundColor: '#334155' },
+ },
+ grid: [
+ { left: 64, right: hasBenchmark ? 64 : 16, top: 14, bottom: '40%' },
+ { left: 64, right: hasBenchmark ? 64 : 16, top: '68%', bottom: 46 },
+ ],
+ xAxis: [
+ {
+ type: 'category', data: dates, gridIndex: 0,
+ axisLabel: { show: false }, axisTick: { show: false },
+ axisPointer: { show: true, type: 'line' },
+ axisLine: { lineStyle: { color: '#334155' } },
+ },
+ {
+ type: 'category', data: dates, gridIndex: 1,
+ axisLabel: { color: '#64748b', fontSize: 10, interval: Math.floor(dates.length / 6) },
+ axisTick: { show: false },
+ axisPointer: { show: true, type: 'line' },
+ axisLine: { lineStyle: { color: '#334155' } },
+ },
+ ],
+ yAxis: [
+ {
+ type: 'value', gridIndex: 0,
+ scale: true,
+ name: hasBenchmark ? '上证点位' : '策略资金',
+ nameTextStyle: { color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
+ axisLabel: {
+ color: hasBenchmark ? 'rgba(148,163,184,0.55)' : '#64748b',
+ fontSize: 10,
+ formatter: hasBenchmark ? ((v: number) => v.toFixed(0)) : axisMoneyFmt,
+ },
+ splitLine: { lineStyle: { color: '#1e293b' } },
+ axisLine: { show: false },
+ },
+ {
+ type: 'value', gridIndex: 0,
+ position: 'right',
+ scale: true,
+ name: hasBenchmark ? '策略资金' : '',
+ nameTextStyle: { color: '#64748b', fontSize: 10, padding: [0, 0, 4, 0] },
+ axisLabel: {
+ show: hasBenchmark,
+ color: '#64748b',
+ fontSize: 10,
+ formatter: axisMoneyFmt,
+ },
+ splitLine: { show: false },
+ axisLine: { show: false },
+ },
+ {
+ type: 'value', gridIndex: 1,
+ position: 'right',
+ max: 0,
+ axisLabel: {
+ color: '#64748b', fontSize: 10,
+ formatter: (v: number) => `${v.toFixed(1)}%`,
+ },
+ splitLine: { lineStyle: { color: '#1e293b' } },
+ axisLine: { show: false },
+ },
+ ],
+ dataZoom: [
+ {
+ type: 'inside',
+ xAxisIndex: [0, 1],
+ filterMode: 'filter',
+ zoomOnMouseWheel: true,
+ moveOnMouseMove: true,
+ moveOnMouseWheel: false,
+ },
+ {
+ type: 'slider',
+ xAxisIndex: [0, 1],
+ filterMode: 'filter',
+ height: 16,
+ bottom: 10,
+ borderColor: 'rgba(148,163,184,0.18)',
+ backgroundColor: 'rgba(15,23,42,0.55)',
+ fillerColor: 'rgba(59,130,246,0.18)',
+ handleStyle: { color: '#64748b', borderColor: '#94a3b8' },
+ textStyle: { color: '#64748b', fontSize: 10 },
+ brushSelect: false,
+ },
+ ],
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(15,23,42,0.95)',
+ borderColor: 'rgba(148,163,184,0.2)',
+ textStyle: { color: '#e2e8f0', fontSize: 12 },
+ formatter: (params: any) => {
+ const date = params[0]?.axisValue ?? ''
+ let html = `${date}
`
+ for (const p of params) {
+ if (p.value == null) continue
+ const isDrawdown = p.seriesName === '回撤'
+ const isBenchmark = p.seriesName === '同期上证指数'
+ html += `
+ ${p.seriesName}
+ ${
+ isDrawdown
+ ? `${(p.value as number).toFixed(2)}%`
+ : isBenchmark
+ ? `${valueFmt.format(p.value as number)} 点`
+ : moneyFmt.format(p.value as number)
+ }
+
`
+ }
+ return html
+ },
+ },
+ series: [
+ {
+ name: '净值',
+ type: 'line',
+ xAxisIndex: 0,
+ yAxisIndex: hasBenchmark ? 1 : 0,
+ data: navValues,
+ symbol: 'none',
+ lineStyle: { color: '#3b82f6', width: 2.2 },
+ areaStyle: {
+ color: {
+ type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
+ colorStops: [
+ { offset: 0, color: 'rgba(59,130,246,0.15)' },
+ { offset: 1, color: 'rgba(59,130,246,0.01)' },
+ ],
+ } as any,
+ },
+ },
+ ...(hasBenchmark ? [{
+ name: '同期上证指数',
+ type: 'line',
+ xAxisIndex: 0,
+ yAxisIndex: 0,
+ data: benchmarkValues,
+ symbol: 'none',
+ connectNulls: true,
+ lineStyle: { color: 'rgba(148,163,184,0.45)', width: 1, type: 'dashed' },
+ }] : []),
+ {
+ name: '回撤',
+ type: 'line',
+ xAxisIndex: 1,
+ yAxisIndex: 2,
+ data: ddValues,
+ symbol: 'none',
+ lineStyle: { color: 'rgba(240,68,56,0.6)', width: 1 },
+ areaStyle: { color: 'rgba(240,68,56,0.12)' },
+ },
+ ],
+ } as any
+ }, [result.equity_curve, result.drawdown_curve, result.benchmark_curve, result.run_id])
+
+ const chartRef = useECharts(option, [result.run_id])
+
+ return (
+
+
+
+
+ 策略净值
+
+
+
+ 回撤
+
+ {(result.benchmark_curve?.length ?? 0) > 0 && (
+
+
+ 同期上证指数
+
+ )}
+ 滚轮缩放 · 拖动平移
+
+
+
+ )
+}
diff --git a/frontend/src/pages/backtest/charts/useECharts.ts b/frontend/src/pages/backtest/charts/useECharts.ts
new file mode 100644
index 0000000..6d33c9d
--- /dev/null
+++ b/frontend/src/pages/backtest/charts/useECharts.ts
@@ -0,0 +1,37 @@
+import { useEffect, useRef } from 'react'
+import * as echarts from 'echarts'
+import type { ECharts, EChartsOption } from 'echarts'
+
+/**
+ * ECharts 实例管理 Hook — 自动初始化/resize/销毁。
+ * 返回 ref 绑定到容器 div,和 setOption 方法。
+ */
+export function useECharts(
+ option: EChartsOption | null,
+ deps: any[] = [],
+) {
+ const chartRef = useRef(null)
+ const instanceRef = useRef(null)
+
+ // 初始化 / 销毁
+ useEffect(() => {
+ if (!chartRef.current) return
+ instanceRef.current = echarts.init(chartRef.current, undefined, { renderer: 'canvas' })
+ const handleResize = () => instanceRef.current?.resize()
+ window.addEventListener('resize', handleResize)
+
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ instanceRef.current?.dispose()
+ instanceRef.current = null
+ }
+ }, [])
+
+ // 更新 option
+ useEffect(() => {
+ if (!instanceRef.current || !option) return
+ instanceRef.current.setOption(option, { notMerge: true })
+ }, [option, ...deps])
+
+ return chartRef
+}
diff --git a/frontend/src/pages/backtest/components/TradeKlineModal.tsx b/frontend/src/pages/backtest/components/TradeKlineModal.tsx
new file mode 100644
index 0000000..6f964ff
--- /dev/null
+++ b/frontend/src/pages/backtest/components/TradeKlineModal.tsx
@@ -0,0 +1,170 @@
+import { useEffect, useMemo, useState } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { Clock, X } from 'lucide-react'
+import { StockPanel } from '@/components/StockPanel'
+import type { ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
+import type { StrategyBacktestTrade } from '@/lib/api'
+import { fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
+
+interface Props {
+ trade: StrategyBacktestTrade | null
+ onClose: () => void
+}
+
+function addDays(date: string, days: number): string {
+ const d = new Date(date)
+ d.setDate(d.getDate() + days)
+ return d.toISOString().slice(0, 10)
+}
+
+function fmtMoney(v: number | null | undefined): string {
+ if (v == null || Number.isNaN(Number(v))) return '—'
+ const n = Number(v)
+ const abs = Math.abs(n)
+ if (abs >= 100_000_000) return `${(n / 100_000_000).toFixed(2)}亿`
+ if (abs >= 10_000) return `${(n / 10_000).toFixed(2)}万`
+ return n.toFixed(0)
+}
+
+function fmtSignedMoney(v: number | null | undefined): string {
+ if (v == null || Number.isNaN(Number(v))) return '—'
+ const prefix = Number(v) > 0 ? '+' : ''
+ return `${prefix}${fmtMoney(v)}`
+}
+
+export function TradeKlineModal({ trade, onClose }: Props) {
+ const [showIntraday, setShowIntraday] = useState(false)
+
+ useEffect(() => {
+ if (!trade) return
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose()
+ }
+ document.addEventListener('keydown', handler)
+ return () => document.removeEventListener('keydown', handler)
+ }, [trade, onClose])
+
+ useEffect(() => {
+ if (trade) setShowIntraday(false)
+ }, [trade])
+
+ const dateRange = useMemo(() => {
+ if (!trade) return null
+ return {
+ start: addDays(String(trade.entry_date).slice(0, 10), -45),
+ end: addDays(String(trade.exit_date).slice(0, 10), 20),
+ }
+ }, [trade])
+
+ const ranges = useMemo(() => {
+ if (!trade) return []
+ return [{
+ start: String(trade.entry_date).slice(0, 10),
+ end: String(trade.exit_date).slice(0, 10),
+ label: '持仓区间',
+ color: 'rgba(59,130,246,0.07)',
+ }]
+ }, [trade])
+
+ const priceLines = useMemo(() => {
+ if (!trade) return []
+ const start = String(trade.entry_date).slice(0, 10)
+ const end = String(trade.exit_date).slice(0, 10)
+ return [
+ {
+ value: Number(trade.entry_price),
+ label: `买入价 ${fmtPrice(trade.entry_price)}`,
+ color: '#C74040',
+ start,
+ end,
+ },
+ {
+ value: Number(trade.exit_price),
+ label: `卖出价 ${fmtPrice(trade.exit_price)}`,
+ color: '#2D9B65',
+ start,
+ end,
+ },
+ ]
+ }, [trade])
+
+ return (
+
+ {trade && dateRange && (
+
+
+
+
+
+
+ {trade.symbol}
+ {trade.name || '交易回放'}
+ 交易回放
+
+
+ {String(trade.entry_date).slice(0, 10)} 买入 → {String(trade.exit_date).slice(0, 10)} 卖出 · 持仓 {trade.duration ?? '—'} 天
+
+
+
+
+
买 / 卖
+
{fmtPrice(trade.entry_price)} / {fmtPrice(trade.exit_price)}
+
+
+
盈亏
+
+ {fmtSignedMoney(trade.pnl_amount)} / {fmtPct(trade.pnl_pct)}
+
+
+
setShowIntraday((v) => !v)}
+ className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition-colors ${
+ showIntraday
+ ? 'border border-accent/30 bg-accent/15 text-accent'
+ : 'border border-border bg-elevated text-secondary hover:border-accent/30'
+ }`}
+ >
+
+ 分时
+
+
+
+
+
+
+
+
+ { if (!showIntraday) setShowIntraday(true) }}
+ />
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx
new file mode 100644
index 0000000..2bfbeba
--- /dev/null
+++ b/frontend/src/pages/settings/AI.tsx
@@ -0,0 +1,200 @@
+import { useState, useEffect } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield } from 'lucide-react'
+import { useSettings } from '@/lib/useSharedQueries'
+import { api } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+
+const PRESETS: { label: string; url: string; model: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
+ { label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
+ { label: 'DeepSeek', url: 'https://api.deepseek.com/v1', model: 'deepseek-chat', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' },
+ { label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' },
+]
+
+export function SettingsAIPanel() {
+ const qc = useQueryClient()
+ const settings = useSettings()
+ const s = settings.data
+
+ const [provider, setProvider] = useState('openai_compat')
+ const [baseUrl, setBaseUrl] = useState('')
+ const [apiKey, setApiKey] = useState('')
+ const [model, setModel] = useState('')
+ const [tokenBudget, setTokenBudget] = useState(5_000_000)
+ const [showKey, setShowKey] = useState(false)
+ const [saved, setSaved] = useState(false)
+
+ // 测试
+ const [testing, setTesting] = useState(false)
+ const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
+
+ useEffect(() => {
+ if (!s) return
+ setProvider(s.ai_provider ?? 'openai_compat')
+ setBaseUrl(s.ai_base_url ?? '')
+ setModel(s.ai_model ?? '')
+ setTokenBudget(s.ai_daily_token_budget ?? 500_000)
+ }, [s])
+
+ const save = useMutation({
+ mutationFn: () => api.saveAiSettings({
+ provider, base_url: baseUrl, api_key: apiKey || undefined, model, daily_token_budget: tokenBudget,
+ }),
+ onSuccess: () => {
+ setSaved(true); setApiKey(''); qc.invalidateQueries({ queryKey: QK.settings })
+ setTimeout(() => setSaved(false), 2000)
+ },
+ })
+
+ const handleTest = async () => {
+ setTesting(true); setTestResult(null)
+ try {
+ // 先保存当前配置(不保存 Key 仅用于测试时临时存)
+ if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, daily_token_budget: tokenBudget })
+ const r = await api.strategyAiTest()
+ setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}${r.usage ? ` · 消耗 ${r.usage.prompt + r.usage.completion} tokens` : ''}` : (r.error ?? '未知错误') })
+ } catch (e: any) {
+ setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
+ } finally { setTesting(false) }
+ }
+
+ const handlePreset = (p: typeof PRESETS[number]) => {
+ setBaseUrl(p.url); setModel(p.model)
+ }
+
+ const configured = s?.has_ai_key
+ const selectedPreset = PRESETS.find(p => p.url === baseUrl)
+
+ return (
+
+ {/* ===== 状态横幅 ===== */}
+
+
+ {configured ? : }
+
+
+
{configured ? 'AI 已连接' : 'AI 未配置'}
+
+ {configured ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
+
+
+ {configured && (
+
+ {testing ? : }
+ {testing ? '测试中' : '测试'}
+
+ )}
+
+
+ {/* 测试结果 */}
+ {testResult && (
+
+ )}
+
+ {/* ===== 快速预设 ===== */}
+
+
快速配置
+
+ {PRESETS.map(p => (
+
handlePreset(p)}
+ className={`rounded-lg border px-3 py-2 text-left transition-all ${baseUrl === p.url ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface text-secondary hover:border-accent/30'}`}>
+
+ {p.label}
+ {p.partner && 优惠 }
+
+
+ ))}
+
+ {selectedPreset && (
+
+ )}
+
+
+ {/* ===== 自定义配置卡片 ===== */}
+
+
+ 自定义配置
+
+
+ {/* API 地址 + 模型 同行 */}
+
+
+ {/* API Key */}
+
+
API Key
+
+
+ setApiKey(e.target.value)}
+ placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'}
+ className="w-full h-8 px-2.5 pr-8 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
+ setShowKey(v => !v)}
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted">
+ {showKey ? : }
+
+
+
+ {testing ? : }
+ 测试
+
+
+
+
+ {/* Token 预算 */}
+
+
每日 Token 预算
+
+ setTokenBudget(Math.max(10000, Number(e.target.value) || 0))}
+ min={10000} step={100000}
+ className="w-44 h-8 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow" />
+ 超出后仅发出提醒,不阻止 AI 调用
+
+
+
+
+
+ {/* ===== 安全提示 ===== */}
+
+
+
+ API Key 仅保存在本机项目文件,不上传至任何服务器。请妥善保管,勿泄露给他人。
+
+
+
+ {/* ===== 保存 ===== */}
+
save.mutate()} disabled={save.isPending || !baseUrl || !model}
+ className="w-full h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
+ {save.isPending ? : saved ? : }
+ {save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/CustomSignals.tsx b/frontend/src/pages/settings/CustomSignals.tsx
new file mode 100644
index 0000000..b4bb2ea
--- /dev/null
+++ b/frontend/src/pages/settings/CustomSignals.tsx
@@ -0,0 +1,259 @@
+import { useState } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Save, Trash2, X, Zap, ArrowRight, Settings2 } from 'lucide-react'
+import { api, type CustomSignal, type CustomSignalCondition } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+
+const KIND_LABEL: Record = { entry: '买入', exit: '卖出', both: '买卖通用' }
+
+const emptySignal = (): CustomSignal => ({
+ id: '', name: '', kind: 'exit', enabled: true,
+ conditions: [{ left: 'close', op: '>', right: 'ma20' }],
+})
+
+export function SettingsCustomSignalsPanel() {
+ const qc = useQueryClient()
+ const list = useQuery({ queryKey: QK.customSignals, queryFn: api.customSignalsList })
+ const options = useQuery({ queryKey: QK.customSignalsOptions, queryFn: api.customSignalsOptions })
+
+ const [showForm, setShowForm] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [draft, setDraft] = useState(emptySignal())
+ const [error, setError] = useState('')
+
+ const fields = options.data?.fields ?? []
+ const operators = options.data?.operators ?? ['>', '>=', '<', '<=', '==', '!=']
+
+ const resetForm = () => {
+ setEditing(null)
+ setDraft(emptySignal())
+ setError('')
+ }
+
+ const openNew = () => { resetForm(); setShowForm(true) }
+ const openEdit = (sig: CustomSignal) => {
+ setEditing(sig)
+ setDraft({ ...sig, conditions: sig.conditions.map(c => ({ ...c })) })
+ setError('')
+ setShowForm(true)
+ }
+
+ const save = useMutation({
+ mutationFn: () => {
+ const d = draft
+ if (!d.id.trim()) throw new Error('请输入信号标识')
+ if (!/^[a-z0-9_]{1,40}$/.test(d.id)) throw new Error('标识仅允许小写字母、数字、下划线(1-40字符)')
+ if (!d.name.trim()) throw new Error('请输入信号名称')
+ if (d.conditions.length === 0) throw new Error('至少需要一个条件')
+ for (const c of d.conditions) {
+ if (!c.left || !c.op || c.right === '') throw new Error('条件填写不完整')
+ }
+ return api.customSignalSave(d)
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: QK.customSignals })
+ setShowForm(false)
+ resetForm()
+ },
+ onError: err => setError(String((err as any)?.message ?? err)),
+ })
+
+ const del = useMutation({
+ mutationFn: api.customSignalDelete,
+ onSuccess: () => qc.invalidateQueries({ queryKey: QK.customSignals }),
+ })
+
+ // 条件编辑辅助
+ const updateCond = (idx: number, patch: Partial) => {
+ setDraft(d => ({ ...d, conditions: d.conditions.map((c, i) => i === idx ? { ...c, ...patch } : c) }))
+ }
+ const addCond = () => setDraft(d => ({ ...d, conditions: [...d.conditions, { left: 'close', op: '>', right: '0' }] }))
+ const removeCond = (idx: number) => setDraft(d => ({ ...d, conditions: d.conditions.filter((_, i) => i !== idx) }))
+
+ const toggleEnabled = (sig: CustomSignal) => {
+ api.customSignalSave({ ...sig, enabled: !sig.enabled }).then(() => qc.invalidateQueries({ queryKey: QK.customSignals }))
+ }
+
+ const signals = list.data?.signals ?? []
+
+ return (
+
+
+
+
+
自定义信号
+
用「字段 + 运算符 + 值」组合买卖信号
+
+ 无需写代码,挑选已有指标字段组合条件(如 最低价 ≤ MA5 ),即可在回测与监控中作为买卖信号使用。多条件间为「且」关系。
+
+
+
+
+ 新建信号
+
+
+
+
+ {showForm && (
+
+
+
+
{editing ? '编辑信号' : '新建信号'}
+
标识保存后不可修改,如需更换请新建。
+
+
{ setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
+
+
+
+
+
+
+ 信号标识
+ setDraft(d => ({ ...d, id: e.target.value.replace(/[^a-z0-9_]/g, '') }))}
+ placeholder="如 low_touches_ma5"
+ className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs font-mono text-foreground disabled:opacity-60"
+ />
+
+
+ 信号名称
+ setDraft(d => ({ ...d, name: e.target.value }))} placeholder="如 跌至MA5" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
+
+
+ 类型
+ setDraft(d => ({ ...d, kind: e.target.value as CustomSignal['kind'] }))} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
+ 买入
+ 卖出
+ 买卖通用
+
+
+
+
+ {/* 条件组 */}
+
+
+
条件(多条件为「且」关系)
+
+ 添加条件
+
+
+ {draft.conditions.map((c, i) => (
+
+ {i === 0 ? '当' : '且'}
+ updateCond(i, { left: e.target.value })} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
+ {fields.map(f => {f.label} )}
+
+ updateCond(i, { op: e.target.value })} className="w-12 h-7 px-1 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50">
+ {operators.map(op => {op} )}
+
+ updateCond(i, { right: v })} />
+ {draft.conditions.length > 1 && (
+ removeCond(i)} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer">
+
+
+ )}
+
+ ))}
+
+
+ {error && {error}
}
+
+
+ { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs">取消
+ save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-amber-500/90 text-base text-xs font-medium disabled:opacity-50">
+ 保存
+
+
+
+ )}
+
+
+ {signals.map(sig => (
+
+
+
+
+
{sig.name}
+
+ {KIND_LABEL[sig.kind]}
+
+ {!sig.enabled && 已停用 }
+
+
{sig.id}
+
+
+ toggleEnabled(sig)} title={sig.enabled ? '停用' : '启用'} className={`p-1 rounded cursor-pointer ${sig.enabled ? 'text-emerald-400 hover:bg-emerald-400/10' : 'text-muted hover:bg-elevated'}`}>
+
+
+ openEdit(sig)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10 cursor-pointer" title="编辑">
+
+
+ del.mutate(sig.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10 cursor-pointer" title="删除">
+
+
+
+
+
+ {sig.conditions.map((c, i) => (
+
+ {i === 0 ? '当' : '且'}
+ {fieldLabel(c.left, fields)}
+ {c.op}
+ {rightDisplay(c.right, fields)}
+
+ ))}
+
+
+ ))}
+ {signals.length === 0 && (
+
+ 暂无自定义信号,点击右上角「新建信号」。
+
+ )}
+
+
+ )
+}
+
+// ── 右值输入:可填数字,也可选「字段引用」────────────────
+function RightValueInput({ cond, fields, onChange }: { cond: CustomSignalCondition; fields: { key: string; label: string }[]; onChange: (v: string) => void }) {
+ const isField = cond.right.startsWith('field:')
+ const fieldValue = isField ? cond.right.slice(6) : ''
+ const numValue = isField ? '' : cond.right
+
+ return (
+
+ {isField ? (
+ <>
+
onChange(`field:${e.target.value}`)} className="w-32 h-7 px-1.5 rounded bg-base border border-border text-[11px] text-foreground focus:outline-none focus:border-accent/50">
+ {fields.map(f => {f.label} )}
+
+
onChange('0')} title="切换为数字" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
+
+
+ >
+ ) : (
+ <>
+
onChange(e.target.value)} step="any" className="w-24 h-7 px-1.5 rounded bg-base border border-border text-[11px] font-mono text-foreground text-center focus:outline-none focus:border-accent/50" />
+
onChange('field:close')} title="切换为字段" className="p-0.5 rounded text-muted hover:text-accent cursor-pointer">
+
+
+ >
+ )}
+
+ )
+}
+
+function fieldLabel(key: string, fields: { key: string; label: string }[]): string {
+ return fields.find(f => f.key === key)?.label ?? key
+}
+
+function rightDisplay(right: string, fields: { key: string; label: string }[]): string {
+ if (right.startsWith('field:')) return fieldLabel(right.slice(6), fields)
+ return right
+}
diff --git a/frontend/src/pages/settings/ExtPages.tsx b/frontend/src/pages/settings/ExtPages.tsx
new file mode 100644
index 0000000..be7ee85
--- /dev/null
+++ b/frontend/src/pages/settings/ExtPages.tsx
@@ -0,0 +1,291 @@
+import { useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { ExternalLink, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
+import { api, type AnalysisColumn, type AnalysisMenu, type ExtDataConfig, type ExtDataField } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+
+function dtypeToColumnType(dtype: string): AnalysisColumn['type'] {
+ return dtype === 'int' || dtype === 'float' ? 'number' : 'string'
+}
+
+function buildColumn(field: ExtDataField): AnalysisColumn {
+ return {
+ field: field.name,
+ label: field.label || field.name,
+ type: dtypeToColumnType(field.dtype),
+ precision: field.dtype === 'float' ? 2 : null,
+ sortable: field.dtype === 'int' || field.dtype === 'float',
+ visible: true,
+ }
+}
+
+function firstMatchingField(config: ExtDataConfig | undefined, keywords: string[]) {
+ if (!config) return ''
+ for (const keyword of keywords) {
+ const lower = keyword.toLowerCase()
+ const matched = config.fields.find(f => f.name.toLowerCase().includes(lower) || f.label.toLowerCase().includes(lower))
+ if (matched) return matched.name
+ }
+ return config.fields.find(f => !['symbol', 'code'].includes(f.name) && f.dtype === 'string')?.name ?? ''
+}
+
+export function SettingsExtPagesPanel() {
+ const qc = useQueryClient()
+ const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
+ const extData = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
+ const configs = extData.data?.items ?? []
+ const menuItems = menus.data?.items ?? []
+
+ const [showForm, setShowForm] = useState(false)
+ const [editingMenu, setEditingMenu] = useState(null)
+ const [id, setId] = useState('')
+ const [label, setLabel] = useState('')
+ const [dataSource, setDataSource] = useState('')
+ const [template, setTemplate] = useState<'dimension_rank' | 'ranking' | 'table'>('dimension_rank')
+ const [dimensionField, setDimensionField] = useState('')
+ const [rankField, setRankField] = useState('')
+ const [selectedColumns, setSelectedColumns] = useState([])
+ const [error, setError] = useState('')
+
+ const activeConfig = configs.find(c => c.id === dataSource) ?? configs[0]
+ const fields = activeConfig?.fields ?? []
+ const numericFields = useMemo(() => fields.filter(f => f.dtype === 'int' || f.dtype === 'float'), [fields])
+
+ const resetForm = () => {
+ const cfg = configs[0]
+ setEditingMenu(null)
+ setId('')
+ setLabel('')
+ setDataSource(cfg?.id ?? '')
+ setTemplate('dimension_rank')
+ setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
+ setRankField('')
+ setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
+ setError('')
+ }
+
+ const editMenu = (menu: AnalysisMenu) => {
+ const cfg = configs.find(c => c.id === menu.data_source)
+ setEditingMenu(menu)
+ setId(menu.id)
+ setLabel(menu.label)
+ setDataSource(menu.data_source)
+ setTemplate(menu.template)
+ setDimensionField(menu.dimension_field ?? firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
+ setRankField(menu.rank_field ?? '')
+ setSelectedColumns(menu.detail_columns.map(c => c.field))
+ setError('')
+ setShowForm(true)
+ }
+
+ const save = useMutation({
+ mutationFn: () => {
+ const cfg = activeConfig
+ if (!cfg) throw new Error('请选择扩展数据源')
+ if (!id.trim()) throw new Error('请输入菜单标识')
+ if (!label.trim()) throw new Error('请输入菜单名称')
+ if (template === 'dimension_rank' && !dimensionField) throw new Error('请选择分组字段')
+ if (template === 'ranking' && !rankField) throw new Error('请选择排名字段')
+
+ const detailColumns = selectedColumns
+ .map(name => cfg.fields.find(f => f.name === name))
+ .filter(Boolean)
+ .map(f => buildColumn(f as ExtDataField))
+ const groupColumns: AnalysisColumn[] = template === 'dimension_rank'
+ ? [
+ { field: '__dimension', label: cfg.fields.find(f => f.name === dimensionField)?.label || '分组', type: 'string', visible: true },
+ { field: '__count', label: '股票数', type: 'number', sortable: true, visible: true },
+ ...detailColumns.filter(c => c.type === 'number').slice(0, 2).map(c => ({ ...c, label: `平均${c.label || c.field}`, aggregate: 'avg' as const })),
+ ]
+ : []
+
+ return api.analysisMenuSave(id.trim(), {
+ label: label.trim(),
+ icon: template === 'dimension_rank' ? 'tags' : 'chart',
+ data_source: cfg.id,
+ template,
+ dimension_field: template === 'dimension_rank' ? dimensionField : null,
+ rank_field: template === 'ranking' ? rankField : null,
+ group_columns: groupColumns,
+ detail_columns: detailColumns,
+ default_sort: template === 'ranking' && rankField ? { field: rankField, order: 'desc' } : null,
+ visible: editingMenu?.visible ?? true,
+ order: editingMenu?.order ?? menuItems.length + 100,
+ })
+ },
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: QK.analysisMenus })
+ setShowForm(false)
+ resetForm()
+ },
+ onError: (err) => setError(String((err as any)?.message ?? err)),
+ })
+
+ const del = useMutation({
+ mutationFn: api.analysisMenuDelete,
+ onSuccess: () => qc.invalidateQueries({ queryKey: QK.analysisMenus }),
+ })
+
+ return (
+
+
+
+
+
扩展页面
+
把扩展数据配置成左侧分析菜单
+
+ 选择扩展数据源、分析模板、分组字段和列表列后,系统会生成一个可访问的动态分析页面。
+
+
+
{ resetForm(); setShowForm(true) }}
+ className="inline-flex items-center justify-center gap-1.5 rounded-btn bg-accent/90 px-3 py-1.5 text-xs font-medium text-base hover:bg-accent transition-colors"
+ >
+
+ 新建页面
+
+
+
+
+ {showForm && (
+
+
+
+
{editingMenu ? '编辑扩展页面' : '新建扩展页面'}
+
菜单标识保存后不可在此处直接修改,如需更换标识请新建页面。
+
+
{ setShowForm(false); setError('') }} className="rounded p-1 text-muted hover:bg-elevated hover:text-foreground">
+
+
+
+
+
+
+ 菜单标识
+ setId(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
+ placeholder="如 concept_hot"
+ className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-60"
+ />
+
+
+ 菜单名称
+ setLabel(e.target.value)} placeholder="如 概念热度" className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground" />
+
+
+ 扩展数据源
+ {
+ const cfg = configs.find(c => c.id === e.target.value)
+ setDataSource(e.target.value)
+ setDimensionField(firstMatchingField(cfg, ['概念', 'industry', '行业', 'sector']))
+ setRankField('')
+ setSelectedColumns(cfg?.fields.filter(f => !['symbol', 'code'].includes(f.name)).slice(0, 6).map(f => f.name) ?? [])
+ }}
+ className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground"
+ >
+ {configs.map(cfg => {cfg.label} )}
+
+
+
+
+
+
+ 模板
+ setTemplate(e.target.value as any)} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground">
+ 维度热度榜
+ 指标排名榜
+ 明细表
+
+
+
+ 分组字段
+ setDimensionField(e.target.value)} disabled={template !== 'dimension_rank'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
+ 请选择
+ {fields.map(f => {f.label || f.name} )}
+
+
+
+ 排名字段
+ setRankField(e.target.value)} disabled={template !== 'ranking'} className="h-9 w-full rounded-btn border border-border bg-base px-3 text-xs text-foreground disabled:opacity-50">
+ 请选择
+ {numericFields.map(f => {f.label || f.name} )}
+
+
+
+
+
+
列表列配置
+
+ {fields.filter(f => !['symbol', 'code'].includes(f.name)).map(f => {
+ const active = selectedColumns.includes(f.name)
+ return (
+ setSelectedColumns(cols => active ? cols.filter(c => c !== f.name) : [...cols, f.name])}
+ className={`rounded-full border px-3 py-1 text-[11px] transition-colors ${active ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-elevated/40 text-secondary hover:bg-elevated'}`}
+ >
+ {f.label || f.name}
+
+ )
+ })}
+
+
+
+ {error && {error}
}
+
+
+ { setShowForm(false); setError('') }} className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs">取消
+ save.mutate()} disabled={save.isPending} className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium disabled:opacity-50">
+ 保存
+
+
+
+ )}
+
+
+ {menuItems.map(menu => (
+
+
+
+
+
{menu.label}
+ {menu.builtin && 默认 }
+ {!menu.visible && 已隐藏 }
+
+
{menu.id}
+
+
+
editMenu(menu)} className="p-1 rounded text-muted hover:text-accent hover:bg-accent/10" title="编辑">
+
+
+ {!menu.builtin && (
+
del.mutate(menu.id)} disabled={del.isPending} className="p-1 rounded text-muted hover:text-danger hover:bg-danger/10" title="删除">
+
+
+ )}
+
+
+
+
数据源:{menu.data_source}
+
模板:{menu.template}
+ {menu.dimension_field &&
分组字段:{menu.dimension_field}
}
+
列表列:{menu.detail_columns.length} 个
+
+
+
+ 打开分析页
+
+
+ ))}
+ {menuItems.length === 0 && (
+ 暂无扩展页面,点击右上角新建。
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/Keys.tsx b/frontend/src/pages/settings/Keys.tsx
new file mode 100644
index 0000000..7dec688
--- /dev/null
+++ b/frontend/src/pages/settings/Keys.tsx
@@ -0,0 +1,373 @@
+import { useState } from 'react'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { motion } from 'framer-motion'
+import {
+ Key,
+ Eye,
+ EyeOff,
+ Trash2,
+ CheckCircle2,
+ AlertCircle,
+ RefreshCw,
+ Activity,
+ ExternalLink,
+ Loader2,
+ Save,
+ Check,
+ Copy,
+} from 'lucide-react'
+import { api } from '@/lib/api'
+import { useCapabilities, useSettings } from '@/lib/useSharedQueries'
+import { QK } from '@/lib/queryKeys'
+import { CAP_LABELS } from '@/lib/capability-labels'
+
+// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
+
+export function SettingsKeysPanel() {
+ const qc = useQueryClient()
+
+ const settings = useSettings()
+ const caps = useCapabilities()
+
+ const [keyInput, setKeyInput] = useState('')
+ const [revealing, setRevealing] = useState(false)
+ const [confirmClear, setConfirmClear] = useState(false)
+ const [saved, setSaved] = useState(false)
+ const [copiedCode, setCopiedCode] = useState(false)
+
+ const save = useMutation({
+ mutationFn: () => api.saveTickflowKey(keyInput.trim()),
+ onSuccess: () => {
+ setKeyInput('')
+ setSaved(true)
+ qc.invalidateQueries({ queryKey: QK.settings })
+ qc.invalidateQueries({ queryKey: QK.capabilities })
+ setTimeout(() => setSaved(false), 2000)
+ },
+ })
+
+ const clear = useMutation({
+ mutationFn: () => api.clearTickflowKey(),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: QK.settings })
+ qc.invalidateQueries({ queryKey: QK.capabilities })
+ },
+ })
+
+ const redetect = useMutation({
+ mutationFn: api.redetectCapabilities,
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: QK.settings })
+ qc.invalidateQueries({ queryKey: QK.capabilities })
+ },
+ })
+
+ const mode = settings.data?.mode
+ const masked = settings.data?.tickflow_api_key_masked
+ const capCount = caps.data ? Object.keys(caps.data.capabilities).length : 0
+
+ return (
+ <>
+
+ {/* ========== 左列: Key 配置 ========== */}
+
+
+
+ 在{' '}
+
+ tickflow.org
+
+ {' '}
+ 注册获取。API Key 存放为本地文件,不会上传任何第三方,请妥善保管。
+
+
+ 通过上方链接注册或填写邀请码{' '}
+
+ V3KDKGXPEA
+ {
+ navigator.clipboard?.writeText('V3KDKGXPEA').then(() => {
+ setCopiedCode(true)
+ setTimeout(() => setCopiedCode(false), 1500)
+ })
+ }}
+ className="text-muted hover:text-accent transition-colors duration-150 ease-smooth self-center"
+ aria-label="复制邀请码"
+ tabIndex={-1}
+ >
+ {copiedCode ? : }
+
+
+ ,即可免费领取概念行业等扩展数据。
+
+
+ {/* 当前状态 */}
+
+
+
状态
+
+ {mode === 'api_key' ? (
+ <>
+
+
已配置
+
{masked}
+ >
+ ) : (
+ <>
+
+
Free 试用
+ >
+ )}
+
+
+ {mode === 'api_key' && (
+
setConfirmClear(true)}
+ disabled={clear.isPending}
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated text-secondary hover:text-danger text-xs transition-colors duration-150 ease-smooth disabled:opacity-50 shrink-0"
+ >
+
+ 清除
+
+ )}
+
+
+ {/* 输入 */}
+
+
+ {save.isError && (
+
+ 保存失败:{String((save.error as any).message)}
+
+ )}
+ {save.data?.ok && (
+
+
+ 保存成功 — 检测到 {save.data.capabilities_count} 项功能,档位 {save.data.tier_label}
+
+ )}
+
+
+
+ {/* ========== 右列: 档位 + 能力 ========== */}
+
+
redetect.mutate()}
+ disabled={redetect.isPending}
+ className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn bg-elevated hover:bg-elevated/80 text-xs text-secondary transition-colors duration-150 ease-smooth disabled:opacity-50"
+ >
+
+ 重新检测
+
+ }
+ >
+ {caps.data ? (
+ <>
+
+ {caps.data.label}
+
+
+ 根据 API Key 自动检测 · 拥有"代表性 capability"任一即认为该档
+
+
+ {settings.data?.missing_caps && settings.data.missing_caps.length > 0 && (
+
+
+ 本档应有但未探测到({settings.data.missing_caps.length} 项)
+
+
+ {settings.data.missing_caps.map((c) => (
+
+ {CAP_LABELS[c]?.name ?? c}
+
+ ))}
+
+
+ )}
+ >
+ ) : (
+ 加载中…
+ )}
+
+
+
+ {caps.data && (
+
+
+ {Object.entries(caps.data.capabilities).map(([cap, lim]) => {
+ const meta = CAP_LABELS[cap]
+ return (
+
+
+
+ {meta?.name ?? cap}
+
+ {meta?.hint && (
+
+ {meta.hint}
+
+ )}
+
+
+
+ {lim.rpm ? `${lim.rpm}/min` : lim.subscribe ? `${lim.subscribe} 订阅` : '—'}
+
+ {lim.batch && (
+
{lim.batch} 只/次
+ )}
+
+
+ )
+ })}
+
+
+ )}
+
+ {settings.data?.probe_log && settings.data.probe_log.length > 0 && (
+
+
+ 查看检测日志
+
+
+ {settings.data.probe_log.map((line, i) => (
+
{line}
+ ))}
+
+
+ )}
+
+
+
+
+ {/* 确认清除 Key 弹窗 */}
+ {confirmClear && (
+
+
setConfirmClear(false)}
+ />
+
+
清除 API Key
+
+ 清除后将退回 Free 模式,需要重新输入 Key 才能恢复。
+
+
+ setConfirmClear(false)}
+ className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors"
+ >
+ 取消
+
+ { setConfirmClear(false); clear.mutate() }}
+ disabled={clear.isPending}
+ className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50"
+ >
+ {clear.isPending ? '清除中...' : '确认清除'}
+
+
+
+
+ )}
+ >
+ )
+}
+
+// ===== 通用卡片 =====
+
+interface CardProps {
+ icon: React.ComponentType<{ className?: string }>
+ title: string
+ badge?: string
+ right?: React.ReactNode
+ children: React.ReactNode
+}
+
+function Card({ icon: Icon, title, badge, right, children }: CardProps) {
+ return (
+
+
+
+
+
{title}
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {right}
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/pages/settings/MenuSettings.tsx b/frontend/src/pages/settings/MenuSettings.tsx
new file mode 100644
index 0000000..74c05ad
--- /dev/null
+++ b/frontend/src/pages/settings/MenuSettings.tsx
@@ -0,0 +1,278 @@
+import { useMemo, useState } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core'
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ useSortable,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { Eye, EyeOff, ExternalLink, GripVertical, Settings } from 'lucide-react'
+import { Link } from 'react-router-dom'
+import { api } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+import { usePreferences } from '@/lib/useSharedQueries'
+
+interface NavEntry {
+ id: string
+ label: string
+ type: 'builtin' | 'analysis'
+ visible: boolean
+}
+
+const BUILTIN_PAGES: NavEntry[] = [
+ { id: '/', label: '看板', type: 'builtin', visible: true },
+ { id: '/watchlist', label: '自选', type: 'builtin', visible: true },
+ { id: '/screener', label: '策略', type: 'builtin', visible: true },
+ { id: '/backtest', label: '回测', type: 'builtin', visible: true },
+ { id: '/limit-ladder', label: '连板梯队', type: 'builtin', visible: true },
+ { id: '/concept-analysis', label: '概念分析', type: 'builtin', visible: true },
+ { id: '/industry-analysis', label: '行业分析', type: 'builtin', visible: true },
+ { id: '/stock-analysis', label: '个股分析', type: 'builtin', visible: true },
+ { id: '/financials', label: '财务', type: 'builtin', visible: true },
+ { id: '/indices', label: '指数', type: 'builtin', visible: true },
+ { id: '/trading', label: '交易', type: 'builtin', visible: true },
+ { id: '/monitor', label: '监控通知', type: 'builtin', visible: true },
+ { id: '/data', label: '数据', type: 'builtin', visible: true },
+]
+
+// ── Sortable row ──
+
+function SortableItem({ entry, hidden, onToggleHidden }: {
+ entry: NavEntry
+ hidden: boolean
+ onToggleHidden: (id: string) => void
+}) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({ id: entry.id })
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.6 : 1,
+ zIndex: isDragging ? 10 : undefined,
+ }
+
+ return (
+
+
+
+
+
+
+ {entry.label}
+
+ {hidden && (
+ 已隐藏
+ )}
+ {entry.id}
+
+
+
+ {entry.type === 'builtin' ? '内置' : '扩展'}
+
+
+
+ onToggleHidden(entry.id)}
+ className={`rounded p-1 transition-colors ${
+ hidden
+ ? 'text-muted hover:text-accent hover:bg-accent/10'
+ : 'text-accent hover:bg-accent/10'
+ }`}
+ title={hidden ? '显示' : '隐藏'}
+ >
+ {hidden ? : }
+
+
+
+ {entry.type === 'builtin' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ )
+}
+
+// ── Main panel ──
+
+export function SettingsMenuSettingsPanel() {
+ const qc = useQueryClient()
+ const { data: prefs } = usePreferences()
+ const menus = useQuery({ queryKey: QK.analysisMenus, queryFn: api.analysisMenus })
+
+ const analysisEntries: NavEntry[] = (menus.data?.items ?? []).map(m => ({
+ id: m.id,
+ label: m.label,
+ type: 'analysis' as const,
+ visible: m.visible,
+ }))
+
+ const allEntries = useMemo(() => {
+ const saved = prefs?.nav_order ?? []
+ const entryMap = new Map
()
+ for (const e of BUILTIN_PAGES) entryMap.set(e.id, e)
+ for (const e of analysisEntries) entryMap.set(e.id, e)
+
+ if (saved.length === 0) return [...BUILTIN_PAGES, ...analysisEntries]
+
+ const ordered: NavEntry[] = []
+ const seen = new Set()
+ for (const id of saved) {
+ const entry = entryMap.get(id)
+ if (entry) {
+ ordered.push(entry)
+ seen.add(id)
+ }
+ }
+ for (const e of [...BUILTIN_PAGES, ...analysisEntries]) {
+ if (!seen.has(e.id)) ordered.push(e)
+ }
+ return ordered
+ }, [prefs?.nav_order, analysisEntries])
+
+ const hiddenSet = useMemo(() => new Set(prefs?.nav_hidden ?? []), [prefs?.nav_hidden])
+
+ // Local order state for optimistic drag updates
+ const [localOrder, setLocalOrder] = useState(null)
+ const orderedEntries = useMemo(() => {
+ const order = localOrder ?? prefs?.nav_order ?? []
+ if (!order.length) return allEntries
+ const byId = new Map(allEntries.map(e => [e.id, e]))
+ const result: NavEntry[] = []
+ const seen = new Set()
+ for (const id of order) {
+ const e = byId.get(id)
+ if (e) { result.push(e); seen.add(id) }
+ }
+ for (const e of allEntries) {
+ if (!seen.has(e.id)) result.push(e)
+ }
+ return result
+ }, [localOrder, prefs?.nav_order, allEntries])
+
+ const saveNavOrder = useMutation({
+ mutationFn: (order: string[]) => api.saveNavOrder(order),
+ onSuccess: () => {
+ setLocalOrder(null)
+ qc.invalidateQueries({ queryKey: QK.preferences })
+ },
+ })
+
+ const saveNavHidden = useMutation({
+ mutationFn: (hidden: string[]) => api.saveNavHidden(hidden),
+ onSuccess: () => qc.invalidateQueries({ queryKey: QK.preferences }),
+ })
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ )
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (!over || active.id === over.id) return
+
+ const ids = orderedEntries.map(e => e.id)
+ const oldIdx = ids.indexOf(active.id as string)
+ const newIdx = ids.indexOf(over.id as string)
+ const reordered = arrayMove(ids, oldIdx, newIdx)
+ setLocalOrder(reordered)
+ saveNavOrder.mutate(reordered)
+ }
+
+ const toggleHidden = (id: string) => {
+ const next = new Set(hiddenSet)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ saveNavHidden.mutate([...next])
+ }
+
+ return (
+
+
+ 菜单设置
+ 调整左侧菜单顺序
+
+ 拖动左侧手柄调整菜单排列顺序,点击眼睛图标控制菜单在侧边栏中的显示或隐藏。
+
+
+
+
+
+
+
+ e.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {orderedEntries.map((entry) => (
+
+ ))}
+
+
+
+ {menus.isLoading && (
+ 正在加载菜单...
+ )}
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx
new file mode 100644
index 0000000..4189a33
--- /dev/null
+++ b/frontend/src/pages/settings/Monitoring.tsx
@@ -0,0 +1,398 @@
+import { useState, useCallback } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import {
+ Activity,
+ Shield,
+ Wifi,
+ BarChart3,
+ Plus,
+ X,
+} from 'lucide-react'
+import {
+ usePreferences,
+ useQuoteStatus,
+ useQuoteInterval,
+ useCapabilities,
+} from '@/lib/useSharedQueries'
+import { useUpdateQuoteInterval, useToggleRealtimeQuotes } from '@/lib/useSharedMutations'
+import { api, type StrategyDetail } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+
+// 页面 → 显示名
+const PAGE_LABELS: Record = {
+ 'overview-market': '看板',
+ watchlist: '自选页',
+ 'limit-ladder': '连板梯队',
+}
+
+const SIDEBAR_INDEX_OPTIONS = [
+ { symbol: '000001.SH', name: '上证指数' },
+ { symbol: '399001.SZ', name: '深证成指' },
+ { symbol: '399006.SZ', name: '创业板指' },
+ { symbol: '000680.SH', name: '科创综指' },
+]
+
+// ===== 导出为 Panel 组件 (由 Settings.tsx 嵌入) =====
+
+export function SettingsMonitoringPanel() {
+ const qc = useQueryClient()
+ const { data: prefs } = usePreferences()
+ const { data: caps } = useCapabilities()
+ const { data: quoteStatus } = useQuoteStatus()
+ const { data: intervalData } = useQuoteInterval()
+ const updateInterval = useUpdateQuoteInterval()
+ const toggleQuote = useToggleRealtimeQuotes()
+ const [saving, setSaving] = useState(false)
+
+ const isFreeTier = (caps?.label ?? '').toLowerCase().startsWith('free')
+ const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false
+ const refreshPages = prefs?.sse_refresh_pages ?? {}
+ const monitorEnabled = prefs?.strategy_monitor_enabled ?? false
+ const monitorIds = prefs?.strategy_monitor_ids ?? []
+ const sidebarIndexSymbols = prefs?.sidebar_index_symbols ?? SIDEBAR_INDEX_OPTIONS.map(i => i.symbol)
+ const indicesPinned = prefs?.indices_nav_pinned ?? true
+ const isRunning = quoteStatus?.running ?? false
+ const isTrading = quoteStatus?.is_trading_hours ?? false
+ const interval = intervalData?.interval ?? 10
+ const minInterval = intervalData?.min_interval ?? 5
+ const maxInterval = intervalData?.max_interval ?? 60
+
+ const save = useCallback(async (cfg: Record) => {
+ setSaving(true)
+ try {
+ await api.updateRealtimeMonitorConfig(cfg)
+ qc.invalidateQueries({ queryKey: QK.preferences })
+ } finally {
+ setSaving(false)
+ }
+ }, [qc])
+
+ const handleToggleQuote = useCallback(async (enabled: boolean) => {
+ await toggleQuote.mutateAsync(enabled)
+ qc.invalidateQueries({ queryKey: QK.preferences })
+ qc.invalidateQueries({ queryKey: QK.quoteStatus })
+ }, [toggleQuote, qc])
+
+ const toggleSidebarIndex = useCallback((symbol: string, visible: boolean) => {
+ const selected = new Set(sidebarIndexSymbols)
+ if (visible) selected.add(symbol)
+ else selected.delete(symbol)
+ const next = SIDEBAR_INDEX_OPTIONS
+ .map(item => item.symbol)
+ .filter(s => selected.has(s))
+ save({ sidebar_index_symbols: next })
+ }, [save, sidebarIndexSymbols])
+
+ const toggleIndicesPin = useCallback((pinned: boolean) => {
+ api.updateIndicesNavPinned(pinned).then(() => qc.invalidateQueries({ queryKey: QK.preferences }))
+ }, [qc])
+
+ // Free 档位 — 显示升级提示
+ if (isFreeTier) {
+ return (
+
+
+
实时监控
+
+ 实时行情轮询、策略监控等功能需要 Starter 及以上档位。
+ 升级后可配置轮询间隔、选择监控策略池。
+
+
+ 配置 API Key 升级
+
+
+ )
+ }
+
+ return (
+
+ {/* ========== 左列 ========== */}
+
+ {/* 行情状态 — 开关 + 间隔 */}
+
+
+
+
+
+
+
轮询间隔
+
每轮拉取全市场行情的时间间隔
+
+
+ {interval < 1 ? interval.toFixed(1) : interval.toFixed(0)}s
+
+
+
+ updateInterval.mutate(parseFloat(e.target.value))}
+ className="flex-1 h-1 accent-accent cursor-pointer"
+ />
+
+ {minInterval}s — {maxInterval}s
+
+
+
+
+
+ {/* 页面刷新 */}
+
+
+ 选择哪些页面跟随 SSE 实时刷新数据。关闭的页面不会被推送,
+ 但行情轮询和策略监控不受影响。
+
+
+ {Object.entries(PAGE_LABELS).map(([key, label]) => (
+ save({ sse_refresh_pages: { ...refreshPages, [key]: v } })}
+ />
+ ))}
+
+
+
+
+
+ 选择实时行情开启时,左侧菜单底部显示哪些指数点位和涨跌幅。
+
+
+ {SIDEBAR_INDEX_OPTIONS.map(item => (
+ toggleSidebarIndex(item.symbol, v)}
+ />
+ ))}
+
+
+
+
+
+
+
+ {/* ========== 右列 ========== */}
+
+ {/* 策略监控 */}
+
+
+ 每次行情刷新时自动评估监控池中的策略。命中买入/卖出信号或阈值条件时弹通知。
+ 与当前打开的页面无关 — 后端始终在评估。
+
+ save({ strategy_monitor_enabled: v })}
+ />
+
+
+ 监控策略池 ({monitorIds.length})
+
+
save({ strategy_monitor_ids: ids })}
+ />
+
+
+
+
+ )
+}
+
+
+// ===== 策略池选择器 =====
+
+function StrategyPoolSelector({
+ selectedIds,
+ disabled,
+ onChange,
+}: {
+ selectedIds: string[]
+ disabled: boolean
+ onChange: (ids: string[]) => void
+}) {
+ const [allStrategies, setAllStrategies] = useState(null)
+ const [showAdd, setShowAdd] = useState(false)
+
+ const loadStrategies = useCallback(async () => {
+ const res = await api.strategyList()
+ setAllStrategies(res.strategies)
+ }, [])
+
+ const addStrategy = (id: string) => {
+ if (!selectedIds.includes(id)) {
+ onChange([...selectedIds, id])
+ }
+ setShowAdd(false)
+ }
+
+ const removeStrategy = (id: string) => {
+ onChange(selectedIds.filter((s) => s !== id))
+ }
+
+ const selected = allStrategies?.filter((s) => selectedIds.includes(s.id)) ?? []
+ const available = allStrategies?.filter((s) => !selectedIds.includes(s.id)) ?? []
+
+ return (
+
+ {/* 已选标签 */}
+ {selected.length > 0 ? (
+
+ {selected.map((s) => (
+
+ {s.name}
+ {!disabled && (
+ removeStrategy(s.id)} className="hover:text-foreground">
+
+
+ )}
+ {s.source}
+
+ ))}
+
+ ) : (
+
+ {disabled ? '请先开启策略监控' : '未选择策略'}
+
+ )}
+
+ {/* 添加按钮 */}
+ {!disabled && (
+
+
{
+ if (!allStrategies) loadStrategies()
+ setShowAdd(!showAdd)
+ }}
+ className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px]
+ bg-elevated text-secondary hover:text-foreground transition-colors"
+ >
+
+ 添加策略
+
+ {showAdd && available.length > 0 && (
+
+ {available.map((s) => (
+
addStrategy(s.id)}
+ className="w-full text-left px-3 py-2 hover:bg-elevated transition-colors
+ text-[11px] border-b border-border/50 last:border-0"
+ >
+ {s.name}
+ {s.description}
+
+ ))}
+
+ )}
+ {showAdd && available.length === 0 && allStrategies && (
+
+ 所有策略已在监控池中
+
+ )}
+
+ )}
+
+ )
+}
+
+
+// ===== ToggleRow =====
+
+function ToggleRow({
+ label,
+ desc,
+ checked,
+ onChange,
+}: {
+ label: string
+ desc: string
+ checked: boolean
+ onChange: (v: boolean) => void
+}) {
+ return (
+
+
+
onChange(!checked)}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 ${
+ checked ? 'bg-accent' : 'bg-elevated'
+ }`}
+ >
+
+
+
+ )
+}
+
+
+// ===== 通用卡片 =====
+
+interface CardProps {
+ icon: React.ComponentType<{ className?: string }>
+ title: string
+ badge?: string
+ right?: React.ReactNode
+ children: React.ReactNode
+}
+
+function Card({ icon: Icon, title, badge, right, children }: CardProps) {
+ return (
+
+
+
+
+
{title}
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {right}
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/pages/settings/System.tsx b/frontend/src/pages/settings/System.tsx
new file mode 100644
index 0000000..175c296
--- /dev/null
+++ b/frontend/src/pages/settings/System.tsx
@@ -0,0 +1,135 @@
+/**
+ * 系统设置面板 — 全局行为开关。
+ *
+ * 独立于实时监控, 放置影响整体应用行为的开关项。
+ */
+import { useState, useCallback } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { Settings2, Trash2, RefreshCw } from 'lucide-react'
+import { usePreferences } from '@/lib/useSharedQueries'
+import { api } from '@/lib/api'
+import { QK } from '@/lib/queryKeys'
+import { PageHeader } from '@/components/PageHeader'
+
+export function SettingsSystemPanel() {
+ const qc = useQueryClient()
+ const { data: prefs } = usePreferences()
+ const [saving, setSaving] = useState(false)
+
+ const screenerAutoRun = prefs?.screener_auto_run ?? true
+ const [clearing, setClearing] = useState(false)
+
+ const save = useCallback(async (cfg: Record) => {
+ setSaving(true)
+ try {
+ await api.updateRealtimeMonitorConfig(cfg)
+ qc.invalidateQueries({ queryKey: QK.preferences })
+ } finally {
+ setSaving(false)
+ }
+ }, [qc])
+
+ // 清理浏览器缓存: 清除 react-query 缓存 + 强制重载 (绕过浏览器缓存)
+ // 不动 localStorage (用户列配置/策略池等偏好保留)
+ const handleClearCache = useCallback(() => {
+ setClearing(true)
+ qc.clear()
+ // 加时间戳参数强制浏览器重新下载所有静态资源
+ setTimeout(() => {
+ window.location.href = window.location.pathname + '?_t=' + Date.now()
+ }, 300)
+ }, [qc])
+
+ return (
+ <>
+
+
+
+
+
+
策略页
+
+
+ save({ screener_auto_run: v })}
+ />
+
+
+
+
+
+
缓存
+
+
+
+
+
清理浏览器缓存
+
+ 清除前端缓存并强制重新加载页面 (不影响你的个人配置)
+
+
+
+ {clearing ? (
+
+ ) : (
+
+ )}
+ {clearing ? '清理中…' : '清理并刷新'}
+
+
+
+ >
+ )
+}
+
+
+// ===== ToggleRow =====
+
+function ToggleRow({
+ label,
+ desc,
+ checked,
+ disabled,
+ onChange,
+}: {
+ label: string
+ desc: string
+ checked: boolean
+ disabled?: boolean
+ onChange: (v: boolean) => void
+}) {
+ return (
+
+
+
onChange(!checked)}
+ disabled={disabled}
+ className={`relative inline-flex h-5 w-9 items-center rounded-full shrink-0 transition-colors duration-200 disabled:opacity-50 ${
+ checked ? 'bg-accent' : 'bg-elevated'
+ }`}
+ >
+
+
+
+ )
+}
diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx
new file mode 100644
index 0000000..7d03fd4
--- /dev/null
+++ b/frontend/src/router.tsx
@@ -0,0 +1,54 @@
+import { createBrowserRouter, Navigate } from 'react-router-dom'
+import { Layout } from './components/Layout'
+import { Watchlist } from './pages/Watchlist'
+import { Screener } from './pages/Screener'
+import { Backtest } from './pages/Backtest'
+import { Financials } from './pages/Financials'
+import { Onboarding } from './pages/Onboarding'
+import { Data } from './pages/Data'
+import { Monitor } from './pages/Monitor'
+import { Trading } from './pages/Trading'
+import { Dashboard } from './pages/Dashboard'
+import { AnalysisDetail } from './pages/AnalysisDetail'
+import { ConceptAnalysis } from './pages/ConceptAnalysis'
+import { IndustryAnalysis } from './pages/IndustryAnalysis'
+import { StockAnalysis } from './pages/StockAnalysis'
+import { LimitUpLadder } from './pages/LimitUpLadder'
+import { Branding } from './pages/Branding'
+import { Settings } from './pages/Settings'
+import { Indices } from './pages/Indices'
+import { MinuteDataProbe } from './pages/MinuteDataProbe'
+
+export const router = createBrowserRouter([
+ { path: '/onboarding', element: },
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: 'overview', element: },
+ { path: 'analysis', element: },
+ { path: 'analysis/:menuId', element: },
+ { path: 'concept-analysis', element: },
+ { path: 'industry-analysis', element: },
+ { path: 'stock-analysis', element: },
+ { path: 'watchlist', element: },
+ { path: 'screener', element: },
+ { path: 'backtest', element: },
+ { path: 'financials', element: },
+ { path: 'data', element: },
+ { path: 'monitor', element: },
+ { path: 'trading', element: },
+ { path: 'limit-ladder', element: },
+ { path: 'indices', element: },
+ { path: 'branding', element: },
+ { path: 'settings', element: },
+ // 隐藏路由:分钟K数据探测(不暴露在菜单,仅供调试)
+ { path: 'minute-probe', element: },
+ // 旧路由兼容重定向
+ { path: 'settings/keys', element: },
+ { path: 'settings/ai', element: },
+ { path: 'settings/queries', element: },
+ ],
+ },
+])
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..b0c27ec
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,6 @@
+///
+
+declare module '*.css' {
+ const content: string
+ export default content
+}
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts
new file mode 100644
index 0000000..00e45f3
--- /dev/null
+++ b/frontend/tailwind.config.ts
@@ -0,0 +1,44 @@
+import type { Config } from 'tailwindcss'
+import animate from 'tailwindcss-animate'
+
+// 设计语言 §6.0:暗色为主 + 电光蓝强调 + 等宽数字
+export default {
+ darkMode: ['class'],
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ container: { center: true, padding: '1rem' },
+ extend: {
+ colors: {
+ // §6.0.1 色板 — CSS variables 见 src/index.css
+ base: 'hsl(var(--base) / )',
+ surface: 'hsl(var(--surface) / )',
+ elevated: 'hsl(var(--elevated) / )',
+ border: 'hsl(var(--border) / )',
+ foreground: 'hsl(var(--fg-primary) / )',
+ secondary: 'hsl(var(--fg-secondary) / )',
+ muted: 'hsl(var(--fg-muted) / )',
+ accent: 'hsl(var(--accent) / )',
+ // A 股语义色:仅用于价格 / K 线,不用于 UI 状态
+ bull: 'hsl(var(--bull) / )',
+ bear: 'hsl(var(--bear) / )',
+ warning: 'hsl(var(--warning) / )',
+ danger: 'hsl(var(--danger) / )',
+ },
+ fontFamily: {
+ sans: ['Inter', '"HarmonyOS Sans SC"', '"PingFang SC"', 'system-ui', 'sans-serif'],
+ mono: ['"JetBrains Mono"', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
+ },
+ borderRadius: {
+ card: '8px',
+ btn: '6px',
+ input: '4px',
+ dialog: '12px',
+ },
+ transitionTimingFunction: {
+ // §6.0.4 Linear/Vercel 同款缓动
+ smooth: 'cubic-bezier(0.16, 1, 0.3, 1)',
+ },
+ },
+ },
+ plugins: [animate],
+} satisfies Config
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..dd03b8a
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..ba173d2
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "composite": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.d.ts b/frontend/vite.config.d.ts
new file mode 100644
index 0000000..340562a
--- /dev/null
+++ b/frontend/vite.config.d.ts
@@ -0,0 +1,2 @@
+declare const _default: import("vite").UserConfig;
+export default _default;
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..9a2b1ed
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,35 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 3011,
+ proxy: {
+ // dev 时 /api 转发到 FastAPI
+ '/api': {
+ target: 'http://localhost:3018',
+ // SSE 端点需要禁用缓冲
+ configure: (proxy) => {
+ proxy.on('proxyReq', (_proxyReq, req) => {
+ if (req.url?.includes('/stream')) {
+ _proxyReq.setHeader('Accept', 'text/event-stream');
+ _proxyReq.setHeader('Cache-Control', 'no-cache');
+ _proxyReq.setHeader('Connection', 'keep-alive');
+ }
+ });
+ },
+ },
+ '/health': 'http://localhost:3018',
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ },
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..4041240
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'node:path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 3011,
+ proxy: {
+ // dev 时 /api 转发到 FastAPI
+ '/api': {
+ target: 'http://localhost:3018',
+ // SSE 端点需要禁用缓冲
+ configure: (proxy) => {
+ proxy.on('proxyReq', (_proxyReq, req) => {
+ if (req.url?.includes('/stream')) {
+ _proxyReq.setHeader('Accept', 'text/event-stream')
+ _proxyReq.setHeader('Cache-Control', 'no-cache')
+ _proxyReq.setHeader('Connection', 'keep-alive')
+ }
+ })
+ },
+ },
+ '/health': 'http://localhost:3018',
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: false,
+ },
+})
diff --git a/tiers.yaml b/tiers.yaml
new file mode 100644
index 0000000..c73543d
--- /dev/null
+++ b/tiers.yaml
@@ -0,0 +1,46 @@
+# tf-stocks-panel —— TickFlow 推荐套餐能力对照表(§5.2)
+#
+# 仅用途:
+# 1. 启动期能力探测决定试探顺序
+# 2. UI Tier Label 反查("≈ Pro" 等友好标签)
+# 3. 加购建议时的价格预估
+#
+# 业务代码永远不读这张表,只读运行时探测出的 CapabilitySet。
+# 来源:https://tickflow.org/pricing/ (2026-05-21 抓取)
+# 频率单位:次/分钟。batch 单位:标的/次。
+
+free:
+ quote.by_symbol: { rpm: 10, batch: 5 }
+ kline.daily.by_symbol: { rpm: 10, batch: 1 }
+
+starter:
+ quote.by_symbol: { rpm: 60, batch: 50 }
+ quote.pool: { rpm: 20 }
+ kline.daily.batch: { rpm: 30, batch: 100 }
+ kline.daily.by_symbol: { rpm: 60, batch: 1 }
+ adj_factor: { rpm: 30, batch: 50 }
+
+pro:
+ quote.by_symbol: { rpm: 120, batch: 100 }
+ quote.pool: { rpm: 60 }
+ kline.daily.batch: { rpm: 60, batch: 100 }
+ kline.daily.by_symbol: { rpm: 120, batch: 1 }
+ kline.minute.batch: { rpm: 30, batch: 100 }
+ kline.minute.by_symbol: { rpm: 60, batch: 1 }
+ intraday: { rpm: 30, batch: 1 }
+ depth5: { rpm: 60, batch: 1 }
+ adj_factor: { rpm: 60, batch: 100 }
+
+expert:
+ quote.by_symbol: { rpm: 300, batch: 500 }
+ quote.pool: { rpm: 120 }
+ kline.daily.batch: { rpm: 120, batch: 200 }
+ kline.daily.by_symbol: { rpm: 300, batch: 1 }
+ kline.minute.batch: { rpm: 60, batch: 200 }
+ kline.minute.by_symbol: { rpm: 120, batch: 1 }
+ intraday: { rpm: 120, batch: 1 }
+ intraday.batch: { rpm: 60, batch: 200 }
+ depth5: { rpm: 120, batch: 1 }
+ adj_factor: { rpm: 120, batch: 200 }
+ websocket: { subscribe: 100 }
+ financial: { rpm: 120, batch: 100 }