diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 3d3da53..68f2404 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -341,6 +341,95 @@ def get_daily_batch(request: Request, body: dict): return {"data": result} +@router.post("/minute-batch") +def get_minute_batch(request: Request, body: dict): + """批量获取多只股票某天的分钟K (分时图用)。 + + - 本地优先: 先从 kline_minute parquet 读, 完整的直接用 + - 缺失补拉: 本地不完整的 symbol 用 sync_minute_batch 批量实时拉 (不落库) + - 需 Pro+ 权限 (kline.minute.batch) + """ + from datetime import datetime + import polars as pl + from app.tickflow.capabilities import Cap + + symbols: list[str] = body.get("symbols", []) + trade_date_str: str | None = body.get("date") + if not symbols: + return {"data": {}} + + repo = request.app.state.repo + capset = request.app.state.capabilities + + # 权限守卫: 分钟K批量是 Pro+ 能力 + if not capset.has(Cap.KLINE_MINUTE_BATCH): + raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (kline.minute.batch)") + + trade_date = date.fromisoformat(trade_date_str) if trade_date_str else date.today() + + # 非交易日(周末/节假日)回退到最近有数据的交易日, 否则前端显示空白。 + # 优先用本地分钟K最近日期; 本地从未同步过分钟K时, 回退到日K最近交易日 + # (enriched 最新日一定有, 作为兜底), 确保 TickFlow 能拉到有效数据。 + if trade_date == date.today(): + recent_date = repo.latest_minute_date_global() + if recent_date is None: + recent_date = repo.latest_daily_date() + if recent_date is not None: + trade_date = recent_date + + # Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K + df_local = repo.get_minute_batch(symbols, trade_date) + + # 期望条数 (盘中按当前时刻估算, 盘后 240) + now = datetime.now() + h, m = now.hour, now.minute + if trade_date != date.today(): + expected = 240 + elif h < 9 or (h == 9 and m < 30): + expected = 0 + elif h < 12 or (h == 12 and m == 0): + expected = (h - 9) * 60 + m - 30 + elif h < 13: + expected = 120 + elif h < 15: + expected = 120 + (h - 13) * 60 + m + else: + expected = 240 + + # 按 symbol 分组, 判定哪些不完整需要补拉 + result: dict[str, list[dict]] = {} + incomplete: list[str] = [] + for sym in symbols: + if df_local.is_empty(): + sub = pl.DataFrame() + else: + sub = df_local.filter(pl.col("symbol") == sym).sort("datetime") + if expected > 0 and (sub.is_empty() or len(sub) < expected * 0.9): + incomplete.append(sym) + elif not sub.is_empty(): + result[sym] = sub.to_dicts() + + # Step 2: 缺失的 symbol 批量实时拉取 (不落库) + if incomplete: + start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0) + end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0) + lim = capset.limits(Cap.KLINE_MINUTE_BATCH) + live_df = kline_sync.sync_minute_batch( + incomplete, + start_time=start_time, + end_time=end_time, + batch_size=lim.batch if lim else None, + rpm=lim.rpm if lim else None, + ) + if not live_df.is_empty(): + for sym in incomplete: + sub = live_df.filter(pl.col("symbol") == sym).sort("datetime") + if not sub.is_empty(): + result[sym] = sub.to_dicts() + + return {"data": result} + + @router.get("/minute") def get_minute( request: Request, diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 8efe686..9fcd22f 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -90,6 +90,11 @@ def get_minute_sync_enabled() -> bool: return load().get("minute_sync_enabled", False) +def get_minute_intraday_refresh() -> bool: + """自选列表分时图是否跟随实时行情刷新 (默认关闭, 开启后盘中按 SSE 频率刷新)。""" + return load().get("minute_intraday_refresh", False) + + def get_minute_sync_days() -> int: return max(1, min(30, load().get("minute_sync_days", 5))) @@ -510,6 +515,8 @@ def set_realtime_monitor_config(cfg: dict) -> dict: updates["sidebar_index_symbols"] = [s for s in cfg["sidebar_index_symbols"] if s in allowed] if "screener_auto_run" in cfg: updates["screener_auto_run"] = bool(cfg["screener_auto_run"]) + if "minute_intraday_refresh" in cfg: + updates["minute_intraday_refresh"] = bool(cfg["minute_intraday_refresh"]) if updates: save(updates) return get_realtime_monitor_config() @@ -523,6 +530,7 @@ def get_realtime_monitor_config() -> dict: "strategy_monitor_ids": get_strategy_monitor_ids(), "sidebar_index_symbols": get_sidebar_index_symbols(), "screener_auto_run": get_screener_auto_run(), + "minute_intraday_refresh": get_minute_intraday_refresh(), } diff --git a/backend/app/tickflow/repository.py b/backend/app/tickflow/repository.py index a73e8bc..90efae3 100644 --- a/backend/app/tickflow/repository.py +++ b/backend/app/tickflow/repository.py @@ -1177,6 +1177,27 @@ class KlineRepository: logger.warning("分钟K查询失败: %s", e) return pl.DataFrame() + def get_minute_batch( + self, + symbols: list[str], + trade_date: date, + ) -> pl.DataFrame: + """批量分钟K查询 — 多 symbol 一次 scan_parquet。 + + 用于自选列表分时图: 一次 predicate pushdown 读多只股票当日分钟K, + 避免逐只查询的 N 次 I/O。 + """ + if not symbols: + return pl.DataFrame() + try: + return pl.scan_parquet(self._minute_glob).filter( + pl.col("symbol").is_in(symbols) + & (pl.col("datetime").dt.date() == trade_date) + ).sort(["symbol", "datetime"]).collect() + except Exception as e: # noqa: BLE001 + logger.warning("批量分钟K查询失败: %s", e) + return pl.DataFrame() + # ================================================================ # Polars 查询内部方法 # ================================================================ @@ -1345,6 +1366,18 @@ class KlineRepository: pass return None + def latest_minute_date_global(self) -> date | None: + """全市场最近分钟K日期 (不分 symbol)。用于非交易日回退到上一交易日。""" + try: + with self._lock: + row = self.db.execute( + "SELECT max(CAST(datetime AS DATE)) FROM kline_minute", + ).fetchone() + if row and row[0]: + return row[0] if isinstance(row[0], date) else date.fromisoformat(str(row[0])) + except Exception: # noqa: BLE001 + return None + def earliest_daily_date(self) -> date | None: """本地日K数据的最早日期。""" try: diff --git a/frontend/src/components/ListColumnCustomizer.tsx b/frontend/src/components/ListColumnCustomizer.tsx index 3580576..4481b88 100644 --- a/frontend/src/components/ListColumnCustomizer.tsx +++ b/frontend/src/components/ListColumnCustomizer.tsx @@ -21,8 +21,8 @@ import { X, GripVertical, Plus, ChevronDown, ChevronRight, Database, Settings2, import { api } from '@/lib/api' import { useQuery } from '@tanstack/react-query' import { QK } from '@/lib/queryKeys' -import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig } from '@/lib/list-columns' -import { resolveCandleConfig } from '@/lib/list-columns' +import type { ColumnConfig, ColumnGroup, ExtColumnDisplayConfig, CandleColumnConfig, IntradayColumnConfig } from '@/lib/list-columns' +import { resolveCandleConfig, resolveIntradayConfig } from '@/lib/list-columns' interface ListColumnCustomizerProps { columns: ColumnConfig[] @@ -40,7 +40,7 @@ interface ListColumnCustomizerProps { showStandaloneToggle?: boolean } -function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { +function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, intradayConfig: intradayPanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { col: ColumnConfig onRemove: (id: string) => void onConfig: (id: string | null) => void @@ -48,6 +48,7 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extTableLabel: string extConfig: React.ReactNode candleConfig: React.ReactNode + intradayConfig: React.ReactNode strategiesConfig: React.ReactNode showStandaloneToggle?: boolean onToggleStandalone?: (id: string) => void @@ -57,8 +58,9 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, } = useSortable({ id: col.id }) const isExt = col.source.type === 'ext' const isCandle = col.source.type === 'builtin' && col.source.key === 'candle' + const isIntraday = col.source.type === 'builtin' && col.source.key === 'intraday' const isStrategies = col.source.type === 'builtin' && col.source.key === 'strategies' - const hasConfig = isExt || isCandle || isStrategies + const hasConfig = isExt || isCandle || isIntraday || isStrategies return ( <> @@ -116,7 +118,7 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, - {hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : strategiesConfig)} + {hasConfig && configOpen && (isExt ? extConfig : isCandle ? candlePanel : isIntraday ? intradayPanel : strategiesConfig)} ) } @@ -244,6 +246,21 @@ export function ListColumnCustomizer({ })) }, [columns, onChange]) + const updateIntradayConfig = useCallback((colId: string, patch: Partial) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + return { ...c, intradayConfig: { ...c.intradayConfig, ...patch } } + })) + }, [columns, onChange]) + + const resetIntradayConfig = useCallback((colId: string) => { + onChange(columns.map(c => { + if (c.id !== colId) return c + const { intradayConfig, ...rest } = c + return rest + })) + }, [columns, onChange]) + const toggleGroup = useCallback((groupId: string) => { setExpandedGroups(prev => { const next = new Set(prev) @@ -480,7 +497,7 @@ export function ListColumnCustomizer({ const renderCandleConfig = (col: ColumnConfig) => { const cfg = resolveCandleConfig(col.candleConfig) - // 数值输入:空字符串回退到默认值,便于清空重输 + // 数值输入: onChange 存原始值(不钳制, 允许自由输入), onBlur 钳制边界 const numInput = ( field: keyof CandleColumnConfig, label: string, @@ -489,12 +506,17 @@ export function ListColumnCustomizer({ {label} { const raw = e.target.value - // 调用 resolveCandleConfig 钳制:过大取上限、过小取最小值 + // 空字符串 → 存 undefined (回退到默认值显示); 否则存原始数字 (不钳制) + updateCandleConfig(col.id, { [field]: raw === '' ? undefined : Number(raw) } as Partial) + }} + onBlur={e => { + const raw = e.target.value + // 失焦时钳制: 过大取上限、过小取最小值 const merged = resolveCandleConfig({ ...col.candleConfig, [field]: raw === '' ? undefined : Number(raw) }) - updateCandleConfig(col.id, { [field]: merged[field] }) + updateCandleConfig(col.id, { [field]: merged[field] } as Partial) }} className="flex-1 h-7 rounded bg-elevated border border-border text-foreground text-xs px-2 focus:outline-none focus:border-accent/50 tabular-nums" /> @@ -529,6 +551,56 @@ export function ListColumnCustomizer({ ) } + const renderIntradayConfig = (col: ColumnConfig) => { + const cfg = resolveIntradayConfig(col.intradayConfig) + const numInput = ( + field: keyof IntradayColumnConfig, + label: string, + ) => ( + + ) + return ( + +
+ {numInput('width', '宽度')} + {numInput('height', '高度')} +
+ 宽度 60–300 / 高度 32–200,越界自动钳制到边界 +
+ {col.intradayConfig && ( +
+ +
+ )} +
+
+ ) + } + const renderCheckbox = (checked: boolean) => ( v !== '') + if (f.boards.length > 0) return true + if (f.excludeST) return true + return Object.entries(f).some(([k, v]) => + k !== 'boards' && k !== 'excludeST' && v !== '' && v !== false, + ) } export function countActiveFilters(f: ScreenerFilter): number { @@ -44,6 +53,8 @@ export function countActiveFilters(f: ScreenerFilter): number { if (f.floatCapMin || f.floatCapMax) n++ if (f.volRatioMin) n++ if (f.rsiMin || f.rsiMax) n++ + if (f.boards.length > 0) n++ + if (f.excludeST) n++ return n } @@ -51,6 +62,14 @@ export function applyFilter(rows: any[], f: ScreenerFilter): any[] { if (!filterActive(f)) return rows const num = (v: string) => v === '' ? null : Number(v) return rows.filter((r) => { + // 板块: 用 symbol 判定板块, 必须在选中列表里 + // 全选 5 个板块 = 不过滤 (等价于 boards:[]), 避免 getBoardType 返回 null 的边缘品种被误删 + if (f.boards.length > 0 && f.boards.length < BOARDS.length) { + const board = getBoardType(r.symbol) + if (!board || !f.boards.includes(board)) return false + } + // ST: name 含 ST/*ST/退 的排除 (对齐后端口径 (?i)ST|退) + if (f.excludeST && /ST|退/i.test(String(r.name ?? ''))) return false const close = Number(r.close ?? 0) const v = (field: string) => num(field) // 现价 @@ -95,7 +114,20 @@ export function FilterPanel({ value, onChange, onClose, onReset }: { }) { const set = (key: keyof ScreenerFilter, v: string) => onChange({ ...value, [key]: v }) - const fields: { label: string; min: keyof ScreenerFilter; max: keyof ScreenerFilter; unit: string; step?: string }[] = [ + const toggleBoard = (board: string) => { + const next = value.boards.includes(board) + ? value.boards.filter(b => b !== board) + : [...value.boards, board] + onChange({ ...value, boards: next }) + } + + // 数值字段只引用 string 类型的 key (排除 boards/excludeST), 避免类型混乱 + type NumKey = keyof Pick + const fields: { label: string; min: NumKey; max: NumKey; unit: string; step?: string }[] = [ { label: '现价', min: 'priceMin', max: 'priceMax', unit: '元', step: '0.1' }, { label: '涨跌幅', min: 'changePctMin', max: 'changePctMax', unit: '%' }, { label: '5日涨幅', min: 'momentum5dMin', max: 'momentum5dMax', unit: '%' }, @@ -108,12 +140,71 @@ export function FilterPanel({ value, onChange, onClose, onReset }: { return (
+ {/* 标题栏: 左侧标题 + 激活计数, 右侧重置 + 关闭 */}
- 筛选条件 - + )} + +
+
+ + {/* 板块 + ST 快速筛选 (按钮组) */} +
+ 市场 + {BOARDS.map(board => { + const active = value.boards.includes(board) + return ( + + ) + })} + +
+
{fields.map((f) => { const isRange = f.min !== f.max @@ -146,17 +237,7 @@ export function FilterPanel({ value, onChange, onClose, onReset }: { ) })}
- {filterActive(value) && ( -
- - 输入即生效 · 支持范围筛选 -
- )} +
输入即生效 · 点击市场/ST 按钮切换
) } diff --git a/frontend/src/components/stock-table/MiniIntraday.tsx b/frontend/src/components/stock-table/MiniIntraday.tsx new file mode 100644 index 0000000..9849f59 --- /dev/null +++ b/frontend/src/components/stock-table/MiniIntraday.tsx @@ -0,0 +1,114 @@ +/** 迷你分时折线图(自选列表共享)。 + +用当日分钟K的 close 画一条折线 + 昨收水平基准线 + 分时均线。 +风格仿同花顺/东方财富分时图: +- 价格折线:涨(收盘 ≥ 昨收)红色,跌绿色 — 以昨收价(prevClose)为基准, 不是当日开盘价 +- 昨收基准线:浅灰实线(从开到右),比虚线更明显 +- 分时均线:黄色细线(成交均价,这里用 close 的累计均值近似) +空数据返回等尺寸占位 SVG,保证加载前后尺寸一致(同 MiniCandlestick 模式)。 +*/ +import type { MinuteKlineRow } from '@/lib/api' + +export function MiniIntraday({ rows, prevClose, changePct, width = 100, height = 56 }: { + rows: MinuteKlineRow[] + /** 昨收价 (前收), 用于基准线。无则用 close/changePct 反算 */ + prevClose?: number | null + /** 涨跌幅 (小数, 如 -0.029 = -2.9%), 用于涨跌着色。优先级最高 */ + changePct?: number | null + width?: number + height?: number +}) { + // 空数据:返回等尺寸占位 + if (!rows || rows.length < 2) { + return + } + + const BULL = '#C74040' + const BEAR = '#2D9B65' + const LINE_PREV_CLOSE = '#7A7A85' // 昨收基准线: 深灰实线 + const LINE_AVG = '#E0B84A' // 均线: 暖黄 + + const W = width + const H = height + const padY = 3 + const n = rows.length + + // 涨跌着色: 优先用 changePct (后端 enriched 字段, 最可靠); + // 其次用 prevClose vs lastClose; 最后回退到第一根 open + const lastClose = rows[n - 1].close + const firstOpen = rows[0].open + const isUp = changePct != null + ? changePct >= 0 + : prevClose != null && prevClose > 0 + ? lastClose >= prevClose + : lastClose >= firstOpen + const color = isUp ? BULL : BEAR + + // 昨收基准线: 优先用 prevClose; 其次用 changePct 反算 (close/(1+changePct)); + // 最后回退到第一根 open + const baseline = (prevClose != null && prevClose > 0) + ? prevClose + : (changePct != null && changePct !== 0) + ? lastClose / (1 + changePct) + : firstOpen + + // 价格区间: close + 昨收 + 均线 全部纳入, 确保都在可视范围 + let hi = -Infinity, lo = Infinity + // 累计均价 (分时均线的近似: close 的累计平均) + const avgLine: number[] = [] + let cumSum = 0 + for (let i = 0; i < n; i++) { + const c = rows[i].close + cumSum += c + const avg = cumSum / (i + 1) + avgLine.push(avg) + if (c > hi) hi = c + if (c < lo) lo = c + if (avg > hi) hi = avg + if (avg < lo) lo = avg + } + // 把昨收也纳入区间 + hi = Math.max(hi, baseline) + lo = Math.min(lo, baseline) + const range = hi - lo || 1 + + const yScale = (v: number) => padY + (1 - (v - lo) / range) * (H - padY * 2) + const xScale = (i: number) => (i / (n - 1)) * W + + // 价格折线 points + const pricePoints = rows.map((r, i) => `${xScale(i).toFixed(1)},${yScale(r.close).toFixed(1)}`).join(' ') + // 均线 points + const avgPoints = avgLine.map((v, i) => `${xScale(i).toFixed(1)},${yScale(v).toFixed(1)}`).join(' ') + + // 昨收参考线 y 坐标 + const prevCloseY = yScale(baseline) + + return ( + + {/* 昨收基准线 (深灰实线, 比虚线更明显) */} + + {/* 分时均线 (暖黄细线) */} + + {/* 分时价格折线 */} + + + ) +} diff --git a/frontend/src/components/stock-table/StockDataTable.tsx b/frontend/src/components/stock-table/StockDataTable.tsx index f0317e5..2cc1649 100644 --- a/frontend/src/components/stock-table/StockDataTable.tsx +++ b/frontend/src/components/stock-table/StockDataTable.tsx @@ -5,7 +5,7 @@ * 不内置任何业务逻辑:单元格内容(含 symbol 列交互、操作列、ext 列)由调用方通过 * renderCell / renderExtraCol 注入。这样两个页面的特有交互得以保留,同时表头能力一致。 */ -import type { ReactNode } from 'react' +import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react' import type { ColumnConfig } from '@/lib/list-columns' import { UNSORTABLE_KEYS } from '@/lib/stock-table' import type { SortState } from './useTableSort' @@ -108,7 +108,13 @@ export function StockDataTable({ key={rowKey(r)} className={`transition-colors duration-150 ease-smooth group ${rowClassName(r)}`} > - {visibleColumns.map(col => renderCell(r, col))} + {visibleColumns.map(col => { + // renderCell 返回的 无 key, 这里补上避免 React key 警告 + const cell = renderCell(r, col) + return isValidElement(cell) + ? cloneElement(cell as ReactElement, { key: col.id }) + : cell + })} {renderExtraCol && renderExtraCol(r)} ) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 15e8b5c..1f51a60 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -781,9 +781,8 @@ export interface Preferences { nav_order: string[] nav_hidden: string[] screener_auto_run: boolean + minute_intraday_refresh: boolean } - -// ===== Strategy Alert ===== export interface StrategyAlertEvent { source: 'strategy' | 'depth' type: string @@ -937,6 +936,7 @@ export const api = { strategy_monitor_ids?: string[] sidebar_index_symbols?: string[] screener_auto_run?: boolean + minute_intraday_refresh?: boolean }) => request<{ sse_refresh_pages: Record @@ -944,6 +944,7 @@ export const api = { strategy_monitor_ids: string[] sidebar_index_symbols: string[] screener_auto_run: boolean + minute_intraday_refresh: boolean }>('/api/settings/preferences/realtime-monitor', { method: 'PUT', body: JSON.stringify(cfg), @@ -1064,6 +1065,11 @@ export const api = { method: 'POST', body: JSON.stringify({ symbols, days }), }), + klineMinuteBatch: (symbols: string[], date?: string) => + request<{ data: Record }>('/api/kline/minute-batch', { + method: 'POST', + body: JSON.stringify({ symbols, date }), + }), instrumentSearch: (q: string, limit = 20) => request<{ results: { symbol: string; name: string; code: string }[] }>( `/api/kline/instruments/search?q=${encodeURIComponent(q)}&limit=${limit}`, diff --git a/frontend/src/lib/list-columns.ts b/frontend/src/lib/list-columns.ts index 20db1b3..97cc9d0 100644 --- a/frontend/src/lib/list-columns.ts +++ b/frontend/src/lib/list-columns.ts @@ -49,6 +49,34 @@ export const DEFAULT_CANDLE_CONFIG: Required = { days: 12, } +/** 分时列渲染配置(builtin: intraday 列专用) */ +export interface IntradayColumnConfig { + /** 单元格宽度 px */ + width?: number + /** 单元格高度 px */ + height?: number +} + +/** 分时列配置默认值 */ +export const DEFAULT_INTRADAY_CONFIG: Required = { + width: 150, + height: 80, +} + +/** 分时列数值边界 */ +const INTRADAY_BOUNDS = { + width: { min: 60, max: 300 }, + height: { min: 32, max: 200 }, +} as const + +export function resolveIntradayConfig(cfg: IntradayColumnConfig | undefined): Required { + const c = cfg ?? {} + return { + width: clampNum(c.width, INTRADAY_BOUNDS.width, DEFAULT_INTRADAY_CONFIG.width), + height: clampNum(c.height, INTRADAY_BOUNDS.height, DEFAULT_INTRADAY_CONFIG.height), + } +} + /** 数值边界(设置过大取上限,过小取最小值) */ const CANDLE_BOUNDS = { enabledWidth: { min: 40, max: 300 }, @@ -89,6 +117,8 @@ export interface ColumnConfig { extDisplay?: ExtColumnDisplayConfig /** 日k列渲染配置(仅 builtin: candle 列生效) */ candleConfig?: CandleColumnConfig + /** 分时列渲染配置(仅 builtin: intraday 列生效) */ + intradayConfig?: IntradayColumnConfig /** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */ standalone?: boolean } @@ -135,11 +165,12 @@ export function mergeColumns( const def = defaultMap.get(col.id) if (def) { // 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置; - // 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失 + // 用户自定义的渲染配置(如日k的 candleConfig、分时的 intradayConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失 result.push({ ...def, visible: col.visible, ...(col.candleConfig ? { candleConfig: col.candleConfig } : {}), + ...(col.intradayConfig ? { intradayConfig: col.intradayConfig } : {}), ...(col.extDisplay ? { extDisplay: col.extDisplay } : {}), ...(col.standalone ? { standalone: col.standalone } : {}), }) diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 4dd6fdc..6766ef6 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -26,6 +26,9 @@ export const QK = { watchlistQuotes: ['watchlist-quotes'] as const, watchlistEnriched: (ext?: string) => ['watchlist-enriched', ext] as const, watchlistKlineBatch: (symbols: string) => ['watchlist-kline-batch', symbols] as const, + // 不用 watchlist- 前缀: 避免被 SSE quotes_updated 高频失效(expert 1s/pro 2s) + // 导致每次都拉 TickFlow 触限流。分时图用固定 refetchInterval 刷新即可。 + minuteBatch: (symbols: string) => ['minute-batch', symbols] as const, instrumentSearch: (q: string) => ['instrument-search', q] as const, // Screener diff --git a/frontend/src/lib/stock-table.ts b/frontend/src/lib/stock-table.ts index 2715f5b..50dfdc7 100644 --- a/frontend/src/lib/stock-table.ts +++ b/frontend/src/lib/stock-table.ts @@ -48,7 +48,7 @@ export function signalCls(type: SignalType): string { // ===== 排序 ===== /** 不可参与数值/文本排序的内置列 key(渲染为标签/图表,无单一标量值) */ -export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'strategies']) +export const UNSORTABLE_KEYS = new Set(['signals', 'candle', 'intraday', 'strategies']) /** * 取一列在某行上的排序标量值。builtin 列按 key 映射到行字段;ext 列走 diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index b9e7295..593caaa 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -42,6 +42,9 @@ export const storage = { /** 自选列表日K蜡烛图显示状态 */ watchlistCandle: kv('watchlist_showCandle'), + /** 自选列表分时图显示状态 */ + watchlistIntraday: kv('watchlist_showIntraday'), + /** 策略结果列表日K蜡烛图显示状态 */ screenerCandle: kv('screener_showCandle'), diff --git a/frontend/src/lib/watchlist-columns.ts b/frontend/src/lib/watchlist-columns.ts index 3c69cc4..5e01ebf 100644 --- a/frontend/src/lib/watchlist-columns.ts +++ b/frontend/src/lib/watchlist-columns.ts @@ -71,6 +71,7 @@ export const BUILTIN_COLUMNS: ColumnConfig[] = [ // 信号 & 图表 { id: 'builtin:signals', source: { type: 'builtin', key: 'signals' }, label: '信号', visible: true, align: 'center' }, { id: 'builtin:candle', source: { type: 'builtin', key: 'candle' }, label: '日k', visible: false, align: 'center' }, + { id: 'builtin:intraday', source: { type: 'builtin', key: 'intraday' }, label: '分时', visible: false, align: 'center' }, // 财务指标 (需 Expert 套餐 financial capability, 列默认隐藏) { id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'center' }, { id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'center' }, @@ -92,7 +93,7 @@ export const COLUMN_GROUPS: ColumnGroup[] = [ { id: 'tech', label: '技术指标', icon: '🔬', keys: ['rsi6', 'rsi14', 'rsi24', 'macd_dif', 'macd_dea', 'macd_hist', 'kdj_k', 'kdj_d', 'kdj_j', 'boll_upper', 'boll_lower', 'atr14', 'vol_ma5', 'vol_ma10'] }, { id: 'momentum', label: '动量', icon: '🚀', keys: ['momentum_5d', 'momentum_10d', 'momentum_20d', 'momentum_30d', 'momentum_60d'] }, { id: 'limit', label: '连板', icon: '🔥', keys: ['limit_ups', 'limit_downs'] }, - { id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle'] }, + { id: 'signal', label: '信号', icon: '📡', keys: ['signals', 'candle', 'intraday'] }, { id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'revenue_yoy', 'net_income_yoy', 'debt_ratio'] }, ] diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 3db438b..1bb2be5 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion } from 'framer-motion' -import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store } from 'lucide-react' +import { ScanSearch, Clock, TrendingUp, Star, Filter, Layers, Network, Sparkles, RefreshCw, Settings2, Store, RotateCcw } from 'lucide-react' import { api, genRuleId, type ScreenerStrategy, type ScreenerResult } from '@/lib/api' import { toast } from '@/components/Toast' import { useDataStatus, usePreferences } from '@/lib/useSharedQueries' @@ -669,37 +669,43 @@ export function Screener() { )}
- {displayRows.length > 0 && ( - <> + {(showAll ? allRows.length > 0 : !!result?.rows.length) && ( +
{filterActive(filter) && ( - + <> + + + )} - +
)} {displayRows.length > 0 && (
+ {/* 筛选面板: 只要原始结果有数据就显示 (哪怕筛完后为空, 用户才能改条件) */} + {showFilter && (showAll ? allRows.length > 0 : !!result?.rows.length) && ( + setShowFilter(false)} + onReset={() => { + setFilter(defaultFilter) + if (activeStrategy) filterMap.current.delete(activeStrategy) + }} + /> + )} + {displayRows.length === 0 ? ( ) : ( <> - {showFilter && ( - setShowFilter(false)} - onReset={() => { - setFilter(defaultFilter) - if (activeStrategy) filterMap.current.delete(activeStrategy) - }} - /> - )} - { return storage.watchlistCandle.get(true) }) + const [intradayChartVisible, setIntradayChartVisible] = useState(() => { + return storage.watchlistIntraday.get(true) + }) // 列配置 — 从后端/localStorage 异步加载 const [columns, setColumns] = useState([...BUILTIN_COLUMNS]) @@ -527,6 +531,18 @@ export function Watchlist() { const dailyKVisible = candleColumnEnabled && dailyKChartVisible + // 分时列检测: 用户开启且列可见时才拉数据 + const intradayColumn = useMemo(() => + columns.find(c => c.source.type === 'builtin' && c.source.key === 'intraday' && c.visible), + [columns], + ) + // 分时列渲染配置(宽高, 来自列定制, 已钳制边界) + const intradayResolved = useMemo(() => resolveIntradayConfig(intradayColumn?.intradayConfig), [intradayColumn]) + // 分时图需 Pro+ (kline.minute.batch), 低档用户开了列也不拉数据 + const caps = useCapabilities() + const hasMinuteBatch = !!caps.data?.capabilities?.['kline.minute.batch'] + const intradayVisible = !!intradayColumn && hasMinuteBatch && intradayChartVisible + // 计算可见列(列是否出现只由自定义列配置决定) const visibleColumns = useMemo(() => { return columns.filter(c => c.visible) @@ -549,6 +565,13 @@ export function Watchlist() { return next }) }, []) + const toggleIntradayChart = useCallback(() => { + setIntradayChartVisible(v => { + const next = !v + storage.watchlistIntraday.set(next) + return next + }) + }, []) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const [expandedCells, setExpandedCells] = useState>(new Set()) @@ -591,6 +614,20 @@ export function Watchlist() { const klineData = dailyKVisible ? (klineBatch.data?.data ?? {}) : {} + // 批量分时数据 (Pro+ 用户, 列可见时才拉) + // 刷新策略: 默认关闭(不轮询); 用户在实时监控设置里开启 minute_intraday_refresh 后, + // 盘中按 15s 轮询刷新 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流) + const { data: prefsData } = usePreferences() + const intradayRefreshEnabled = prefsData?.minute_intraday_refresh ?? false + const minuteBatch = useQuery({ + queryKey: QK.minuteBatch(symbolsKey), + queryFn: () => api.klineMinuteBatch(symbols), + enabled: intradayVisible && symbols.length > 0, + staleTime: 10_000, + refetchInterval: intradayRefreshEnabled ? 15_000 : false, + }) + const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {} + const addMutation = useMutation({ mutationFn: (sym: string) => api.watchlistAdd(sym), onSuccess: (data) => { @@ -973,6 +1010,26 @@ export function Watchlist() {
) } + if (col.source.type === 'builtin' && col.source.key === 'intraday') { + return ( + + {col.label} + + + ) + } return undefined }} renderCell={(r: any, col: ColumnConfig) => { @@ -1091,13 +1148,29 @@ export function Watchlist() { if (key === 'candle') { return ( ) } + // 分时列 + if (key === 'intraday') { + const rows: MinuteKlineRow[] = minuteData[r.symbol] ?? [] + // 眼睛关闭(收起)时用小尺寸 (和日k收起态一致 40x40); 开启时用配置值 + const iw = intradayChartVisible ? intradayResolved.width : 40 + const ih = intradayChartVisible ? intradayResolved.height : 40 + return ( + +
+ {intradayChartVisible + ? + : 分时} +
+ + ) + } // 其余纯数据列 → 共享原语 return renderBuiltinDataCell(r, col) }} diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 339795f..628d3d9 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -302,6 +302,16 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = )} + {/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中 15s 轮询刷新分时数据) */} + + save({ minute_intraday_refresh: v })} + /> + + {!isFreeTier && (