diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 748c0b9..308282b 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -80,11 +80,14 @@ def get_daily( days: int = Query(120, ge=10, le=2000), start_date: Optional[str] = Query(None, description="起始日期 YYYY-MM-DD, 优先于 days"), end_date: Optional[str] = Query(None, description="截止日期 YYYY-MM-DD, 默认今天"), + ext_columns: Optional[str] = Query(None, description="逗号分隔的 ext 列: config_id.field_name"), ): """读取本地 enriched 表中某只股票的日 K。 - 若 QuoteService 有实时行情, 追加/覆盖今日实时蜡烛 - Free 用户: 若 enriched 表里没有该股票, 实时拉取 + 本地算 enriched 返回 + - ext_columns: 可选,动态 LEFT JOIN 扩展数据表,结果平铺到 stock_info.ext 下 + (key 为 "{config_id}__{field_name}"),供日K信息条等场景展示自定义字段 """ import polars as pl @@ -112,14 +115,81 @@ def get_daily( rows = enriched.tail(days).to_dicts() # 即使 live 模式也尝试追加实时蜡烛 rows = _maybe_inject_live_candle(request, symbol, rows) - return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"} + resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "live"} + return _attach_ext(resp, repo, symbol, ext_columns) rows = df.to_dicts() # 追加/覆盖今日实时蜡烛 rows = _maybe_inject_live_candle(request, symbol, rows) - return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"} + resp = {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": rows, "source": "enriched"} + return _attach_ext(resp, repo, symbol, ext_columns) + + +def _attach_ext(resp: dict, repo, symbol: str, ext_columns: Optional[str]) -> dict: + """按 ext_columns 规格为单只股票 LEFT JOIN 扩展数据,平铺到 stock_info['ext']。 + + key 形如 "{config_id}__{field_name}",与自选列表 enriched 接口保持一致。 + JOIN 逻辑参考 watchlist.watchlist_enriched;任何 ext 表/字段缺失都静默跳过。 + """ + if not ext_columns or not ext_columns.strip(): + return resp + + specs: list[tuple[str, str]] = [] + for part in ext_columns.split(","): + part = part.strip() + if "." not in part: + continue + config_id, field_name = part.split(".", 1) + config_id, field_name = config_id.strip(), field_name.strip() + if config_id and field_name: + specs.append((config_id, field_name)) + if not specs: + return resp + + import polars as pl + data_dir = repo.store.data_dir + try: + from app.services.ext_data import ExtConfigStore + from app.api.ext_data import _read_ext_dataframe + ext_store = ExtConfigStore(data_dir) + configs = {c.id: c for c in ext_store.load_all()} + except Exception: # noqa: BLE001 + configs = {} + + ext_values: dict = {} + for config_id, field_name in specs: + ext_col_name = f"{config_id}__{field_name}" + value = None + try: + cfg = configs.get(config_id) + if cfg: + ext_df, _ = _read_ext_dataframe(cfg, data_dir) + else: + ext_df = pl.from_arrow( + repo.store.db.query( + f'SELECT symbol, "{field_name}" FROM ext_{config_id}' + ).arrow() + ) + if not ext_df.is_empty() and "symbol" in ext_df.columns and field_name in ext_df.columns: + # 时序表取最新分区,避免一个 symbol 多行 + row = ( + ext_df + .select(["symbol", field_name]) + .unique(subset=["symbol"], keep="last") + .filter(pl.col("symbol") == symbol) + ) + if not row.is_empty(): + value = row[field_name][0] + except Exception as e: # noqa: BLE001 + logger.debug("kline ext join failed for %s.%s: %s", config_id, field_name, e) + ext_values[ext_col_name] = value + + stock_info = dict(resp.get("stock_info") or {}) + stock_info["ext"] = ext_values + resp["stock_info"] = stock_info + return resp def _maybe_inject_live_candle(request: Request, symbol: str, rows: list[dict]) -> list[dict]: diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index b322afb..ec093ea 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -55,6 +55,8 @@ export interface StockInfo { name?: string total_shares?: number float_shares?: number + /** 扩展数据(key: configId__fieldName),来自 klineDaily 的 ext_columns */ + ext?: Record } /** 子图定义 */ diff --git a/frontend/src/components/ListColumnCustomizer.tsx b/frontend/src/components/ListColumnCustomizer.tsx index e803de6..3580576 100644 --- a/frontend/src/components/ListColumnCustomizer.tsx +++ b/frontend/src/components/ListColumnCustomizer.tsx @@ -34,9 +34,13 @@ interface ListColumnCustomizerProps { builtinSectionLabel?: string extColumnAlign?: 'left' | 'center' | 'right' extFieldFilter?: (field: { name: string; label: string; type: string }) => boolean + /** 是否显示扩展数据列区块(默认 true;信息条等无法渲染 ext 数据的场景设为 false)。 */ + showExtColumns?: boolean + /** 是否显示「单独显示」勾选项(默认 false;仅信息条场景启用,让某列独占一行)。 */ + showStandaloneToggle?: boolean } -function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig }: { +function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig, candleConfig: candlePanel, strategiesConfig, showStandaloneToggle, onToggleStandalone }: { col: ColumnConfig onRemove: (id: string) => void onConfig: (id: string | null) => void @@ -45,6 +49,8 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, extConfig: React.ReactNode candleConfig: React.ReactNode strategiesConfig: React.ReactNode + showStandaloneToggle?: boolean + onToggleStandalone?: (id: string) => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, @@ -80,6 +86,19 @@ function SortableActiveCol({ col, onRemove, onConfig, configOpen, extTableLabel, ? `${col.label}(${extTableLabel})` : col.label} + {showStandaloneToggle && ( + + )} {hasConfig && ( + + {showStandaloneToggle && col.visible && ( + + )} + ) const renderExtFieldRow = (configId: string, field: { name: string; label: string; type: string }) => { @@ -596,6 +637,8 @@ export function ListColumnCustomizer({ extConfig={renderExtConfig(col)} candleConfig={renderCandleConfig(col)} strategiesConfig={renderStrategiesConfig(col)} + showStandaloneToggle={showStandaloneToggle} + onToggleStandalone={toggleStandalone} /> ))} @@ -650,7 +693,7 @@ export function ListColumnCustomizer({ })} - {extTables.length > 0 && ( + {showExtColumns && extTables.length > 0 && (
@@ -700,7 +743,7 @@ export function ListColumnCustomizer({
)} - {extTables.length === 0 && extSchema.isSuccess && ( + {showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
暂无扩展数据表,可在「数据」页面创建
diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 0182727..5a57487 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -41,6 +41,8 @@ interface Props { linkedPrice?: number | null onDateClick?: (date: string) => void onDataChange?: (result: StockDailyKChartResult) => void + /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ + extColumns?: string } function isValidRow(r: any): boolean { @@ -121,15 +123,17 @@ export function StockDailyKChart({ linkedPrice, onDateClick, onDataChange, + extColumns, }: Props) { const [activeIndicators, setActiveIndicators] = useState(['vol']) const [showMarkers, setShowMarkers] = useState(true) const dateRange = externalDateRange ?? getDefaultRange() const days = useMemo(() => rangeDays(dateRange), [dateRange]) + // extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数) const kline = useQuery({ - queryKey: QK.kline(symbol, dateRange.start, dateRange.end), - queryFn: () => api.klineDaily(symbol, days, dateRange), + queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns), + queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns), enabled: !!symbol, placeholderData: (prev) => prev, }) diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 77cfbad..2fa3fa6 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -1,5 +1,9 @@ -import type { KlineRow } from '@/lib/api' -import { fmtPrice, fmtBigNum } from '@/lib/format' +import { useState, type ReactNode } from 'react' +import { Settings2, BellRing } from 'lucide-react' +import type { KlineRow, FinancialMetricRecord } from '@/lib/api' +import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' +import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' +import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' const BULL = '#C74040' const BEAR = '#2D9B65' @@ -7,11 +11,96 @@ const BEAR = '#2D9B65' interface Props { symbol: string name?: string - stockInfo?: { name?: string; total_shares?: number; float_shares?: number } + stockInfo?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record } rows: KlineRow[] + /** 信息条字段配置(由 StockPanel 提升,受控) */ + fields: ColumnConfig[] + onFieldsChange: (fields: ColumnConfig[]) => void + /** 财务指标最新一期(来自 useFinancialMetrics,受 Cap.FINANCIAL 门控) */ + financialMetrics?: FinancialMetricRecord } -export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) { +/** + * 精简渲染扩展数据值(信息条专用)。 + * 仍尊重 extDisplay 配置:text=纯文本,tag(默认)=按分隔符拆成小标签 + maxTags 截断。 + * 与自选列表的差异:标签模式无 maxWidth/排列方向,但保留 +N 展开交互。 + */ +function renderExtInline( + val: unknown, + col: ColumnConfig, + expanded: boolean, + onToggle: () => void, +): ReactNode { + if (val == null || (typeof val === 'number' && Number.isNaN(val))) { + return + } + if (typeof val === 'number') { + const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val) + return {displayVal} + } + if (typeof val === 'boolean') { + return {val ? '是' : '否'} + } + const str = String(val) + // 纯文本模式 + if (col.extDisplay?.displayMode === 'text') { + return {str} + } + // 标签模式(默认):按分隔符拆成小标签 + const sep = col.extDisplay?.separator?.trim() || null + const tags = sep + ? str.split(sep).map(s => s.trim()).filter(Boolean) + : str.split(/[、,,;;\-]/).map(s => s.trim()).filter(Boolean) + if (tags.length === 0) return + // maxTags 截断 + 展开交互:收起时显示前 N 个 + +N,展开时显示全部 + 收起 + const maxTags = col.extDisplay?.maxTags ?? 0 + const hiddenIndices = maxTags > 0 ? col.extDisplay?.hiddenIndices : undefined + const showAll = maxTags <= 0 || expanded + const sliced = showAll ? tags : tags.slice(0, maxTags) + const shown = hiddenIndices?.length ? sliced.filter((_, i) => !hiddenIndices.includes(i)) : sliced + const overflow = tags.length - shown.length + return ( + + {shown.map((tag, i) => ( + + {tag} + + ))} + {!showAll && overflow > 0 && ( + + )} + {showAll && maxTags > 0 && tags.length > maxTags && ( + + )} + + ) +} + +export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics }: Props) { + // 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前 + const [customizerOpen, setCustomizerOpen] = useState(false) + // ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰 + const [expandedExt, setExpandedExt] = useState>(new Set()) + + const toggleExtExpand = (key: string) => { + setExpandedExt(prev => { + const next = new Set(prev) + if (next.has(key)) next.delete(key) + else next.add(key) + return next + }) + } + if (rows.length === 0) return null const latest = rows[rows.length - 1] @@ -31,6 +120,79 @@ export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) { : null const displayName = stockInfo?.name ?? name ?? '' + const extData = stockInfo?.ext ?? {} + + // 按指标 key 计算格式化值,无数据返回 null(渲染时跳过,与原行为一致)。 + // 普通函数:依赖行情值每次 render 都变,useCallback 无收益;且必须定义在早期 return 之后。 + const computeBuiltinValue = (key: string): string | null => { + switch (key) { + case 'market_cap': return marketCap != null ? fmtBigNum(marketCap) : null + case 'float_market_cap': return floatMarketCap != null ? fmtBigNum(floatMarketCap) : null + case 'turnover': return turnoverRate != null ? `${turnoverRate.toFixed(2)}%` : null + case 'volume': return latest.volume != null ? fmtVolume(Number(latest.volume)) : null + case 'amplitude': { + const prevClose = prev ? Number(prev.close) : null + if (prevClose == null || prevClose === 0) return null + const hi = Number(latest.high) + const lo = Number(latest.low) + return `${((hi - lo) / prevClose * 100).toFixed(2)}%` + } + case 'open': return fmtPrice(Number(latest.open)) + case 'high': return fmtPrice(Number(latest.high)) + case 'low': return fmtPrice(Number(latest.low)) + // 财务指标:百分比字段存储为百分点(12.3 表示 12.3%),直接 toFixed(2) + % + case 'eps': return financialMetrics?.eps_basic != null ? fmtPrice(financialMetrics.eps_basic) : null + case 'bps': return financialMetrics?.bps != null ? fmtPrice(financialMetrics.bps) : null + case 'roe': return financialMetrics?.roe != null ? `${financialMetrics.roe.toFixed(2)}%` : null + case 'gross_margin':return financialMetrics?.gross_margin != null ? `${financialMetrics.gross_margin.toFixed(2)}%` : null + case 'net_margin': return financialMetrics?.net_margin != null ? `${financialMetrics.net_margin.toFixed(2)}%` : null + case 'debt_ratio': return financialMetrics?.debt_to_asset_ratio != null ? `${financialMetrics.debt_to_asset_ratio.toFixed(2)}%` : null + case 'revenue_yoy': return financialMetrics?.revenue_yoy != null ? `${financialMetrics.revenue_yoy.toFixed(2)}%` : null + case 'net_income_yoy': return financialMetrics?.net_income_yoy != null ? `${financialMetrics.net_income_yoy.toFixed(2)}%` : null + // PE/PB 后端无此字段,用现价现算(PE 基于最新一期 EPS,非严格 TTM) + case 'pe_ttm': { + const eps = financialMetrics?.eps_basic + return eps && eps !== 0 ? fmtPrice(close / eps) : null + } + case 'pb': { + const bps = financialMetrics?.bps + return bps && bps !== 0 ? fmtPrice(close / bps) : null + } + default: return null + } + } + + const visibleFields = fields.filter(f => f.visible) + // 按是否单独显示分组:普通列共一行,standalone 列各占一行 + const inlineFields = visibleFields.filter(f => !f.standalone) + const standaloneFields = visibleFields.filter(f => f.standalone) + + // 渲染单个字段(builtin / ext 通用) + const renderField = (f: ColumnConfig): ReactNode => { + if (f.source.type === 'ext') { + const { configId, fieldName } = f.source + const val = extData[`${configId}__${fieldName}`] + // 无值的 ext 字段整体跳过(与 builtin 无数据行为一致) + if (val == null || (typeof val === 'number' && Number.isNaN(val))) return null + const cellKey = `${symbol}::${f.id}` + return ( + + {f.label} + + {renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))} + + + ) + } + // builtin + const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '') + if (value == null) return null + return ( + + {f.label} {value} + + ) + } return (
@@ -47,20 +209,53 @@ export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) { {isUp ? '+' : ''}{fmtPrice(chgPct)}% + {/* 右侧操作按钮:监控通知 + 信息条配置 */} +
+ + +
- {/* Row 2: market cap, float market cap, turnover rate */} -
- {marketCap != null && ( - 市值 {fmtBigNum(marketCap)} - )} - {floatMarketCap != null && ( - 流通值 {fmtBigNum(floatMarketCap)} - )} - {turnoverRate != null && ( - 换手 {turnoverRate.toFixed(2)}% - )} -
+ {/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap) */} + {inlineFields.length > 0 && ( +
+ {inlineFields.map(renderField)} +
+ )} + + {/* 单独显示的指标:各占一行 */} + {standaloneFields.map(f => { + const node = renderField(f) + if (node == null) return null + return ( +
+ {node} +
+ ) + })} + + setCustomizerOpen(false)} + title="信息条指标" + builtinSectionLabel="可选指标" + extColumnAlign="left" + showStandaloneToggle + />
) } diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 1568fb1..c3b8472 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -1,9 +1,16 @@ -import { useEffect, useState, useCallback } from 'react' -import { type KlineRow } from '@/lib/api' +import { useEffect, useState, useCallback, useRef, useMemo } from 'react' +import { type KlineRow, type FinancialMetricRecord } from '@/lib/api' import { StockInfoBar } from '@/components/StockInfoBar' import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart' import { StockIntradayChart } from '@/components/StockIntradayChart' +import { useFinancialMetrics } from '@/lib/useFinancials' import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick' +import { + loadInfoFields, + saveInfoFields, + buildInfoExtColumnsParam, + type ColumnConfig, +} from '@/lib/stock-info-fields' interface Props { symbol: string @@ -39,6 +46,22 @@ export function StockPanel({ const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) const [dailyResult, setDailyResult] = useState(null) + // 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据 + const [fields, setFields] = useState(loadInfoFields) + const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields]) + + const handleFieldsChange = useCallback((next: ColumnConfig[]) => { + setFields(next) + saveInfoFields(next) + }, []) + + // 财务指标:仅当信息条配置含可见的财务字段时才请求(避免无谓请求 + 受 Cap.FINANCIAL 门控) + const hasFinanceField = useMemo( + () => fields.some(f => f.visible && f.source.type === 'builtin' + && ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)), + [fields], + ) + const financials = useFinancialMetrics(hasFinanceField ? symbol : undefined) const dateRange = externalDateRange ?? getDefaultRange() @@ -51,8 +74,14 @@ export function StockPanel({ const stockInfo = dailyResult?.stockInfo const rawRows: KlineRow[] = dailyResult?.rawRows ?? [] - // symbol 变化时重置分时相关状态,避免切股后残留旧日期 + // symbol 变化时重置分时相关状态,避免切股后残留旧日期。 + // 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存, + // 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据, + // 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。 + const prevSymbol = useRef(symbol) useEffect(() => { + if (prevSymbol.current === symbol) return + prevSymbol.current = symbol setSelectedDate(null) setLinkedPrice(null) setDailyResult(null) @@ -73,6 +102,9 @@ export function StockPanel({ : undefined if (!symbol) return null + // 财务指标最新一期(metrics 按 period_end 排序,取首项) + const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0] + return (
@@ -97,6 +132,7 @@ export function StockPanel({ onDateClick={handleDateClick} onDataChange={setDailyResult} visibleBars={showIntraday ? 40 : 60} + extColumns={extColumns} /> {showIntraday && selectedDate && ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 76fa809..ea6b740 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -697,17 +697,18 @@ export const api = { redetectCapabilities: () => request('/api/capabilities/redetect', { method: 'POST' }), - klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }) => + klineDaily: (symbol: string, days = 120, dateRange?: { start: string; end: string }, extColumns?: string) => request<{ symbol: string name?: string - stock_info?: { name?: string; total_shares?: number; float_shares?: number } + stock_info?: { name?: string; total_shares?: number; float_shares?: number; ext?: Record } rows: KlineRow[] source?: string }>( - dateRange + (dateRange ? `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&start_date=${dateRange.start}&end_date=${dateRange.end}` - : `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`, + : `/api/kline/daily?symbol=${encodeURIComponent(symbol)}&days=${days}`) + + (extColumns ? `&ext_columns=${encodeURIComponent(extColumns)}` : ''), ), klineDailyBatch: (symbols: string[], days = 12) => request<{ data: Record }>('/api/kline/daily-batch', { diff --git a/frontend/src/lib/list-columns.ts b/frontend/src/lib/list-columns.ts index b29d311..20db1b3 100644 --- a/frontend/src/lib/list-columns.ts +++ b/frontend/src/lib/list-columns.ts @@ -89,6 +89,8 @@ export interface ColumnConfig { extDisplay?: ExtColumnDisplayConfig /** 日k列渲染配置(仅 builtin: candle 列生效) */ candleConfig?: CandleColumnConfig + /** 信息条场景:是否单独占一行显示(仅 StockInfoBar 生效,表格场景忽略) */ + standalone?: boolean } export interface ColumnGroup { @@ -133,12 +135,13 @@ export function mergeColumns( const def = defaultMap.get(col.id) if (def) { // 内置列: label/source/align/pinned 以默认定义为准;visible 使用用户配置; - // 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay)需保留,否则刷新后丢失 + // 用户自定义的渲染配置(如日k的 candleConfig、策略列的 extDisplay、信息条 standalone)需保留,否则刷新后丢失 result.push({ ...def, visible: col.visible, ...(col.candleConfig ? { candleConfig: col.candleConfig } : {}), ...(col.extDisplay ? { extDisplay: col.extDisplay } : {}), + ...(col.standalone ? { standalone: col.standalone } : {}), }) } else if (col.source?.type === 'ext') { // ext 列: 保留用户配置,清理旧 label 中的括号后缀 diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 3a41b7d..857d29a 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -48,8 +48,8 @@ export const QK = { analysisMenu: (id: string) => ['analysis-menu', id] as const, // Kline - kline: (symbol: string, start: string, end: string) => - ['kline', symbol, start, end] as const, + kline: (symbol: string, start: string, end: string, extColumns?: string) => + ['kline', symbol, start, end, extColumns ?? ''] as const, klineMinute: (symbol: string, date: string) => ['kline-minute', symbol, date] as const, indexDaily: (symbol: string, start: string, end: string) => diff --git a/frontend/src/lib/stock-info-fields.ts b/frontend/src/lib/stock-info-fields.ts new file mode 100644 index 0000000..d2ababc --- /dev/null +++ b/frontend/src/lib/stock-info-fields.ts @@ -0,0 +1,82 @@ +/** + * 个股日K信息条(StockInfoBar Row 2)的指标自定义配置。 + * + * 与自选列表列配置同源:复用 list-columns 的通用列模型与合并/序列化底座, + * 仅做纯 localStorage 同步持久化(无后端双写)。个股预览弹窗与回测成交K线 + * Modal 共用同一份配置。 + */ + +import { storage } from '@/lib/storage' +import { + buildExtColumnsParam as buildExtColumnsParamBase, + mergeColumns as mergeColumnsBase, + serializeColumns as serializeColumnsBase, + type ColumnConfig, + type ColumnGroup, +} from '@/lib/list-columns' + +export type { ColumnConfig, ColumnGroup } + +// ===== 内置指标注册表 ===== + +export const BUILTIN_INFO_FIELDS: ColumnConfig[] = [ + // 规模 + { id: 'builtin:market_cap', source: { type: 'builtin', key: 'market_cap' }, label: '市值', visible: true, align: 'left' }, + { id: 'builtin:float_market_cap', source: { type: 'builtin', key: 'float_market_cap' }, label: '流通值', visible: true, align: 'left' }, + // 成交 + { id: 'builtin:turnover', source: { type: 'builtin', key: 'turnover' }, label: '换手', visible: true, align: 'left' }, + { id: 'builtin:volume', source: { type: 'builtin', key: 'volume' }, label: '成交量', visible: false, align: 'left' }, + { id: 'builtin:amplitude', source: { type: 'builtin', key: 'amplitude' }, label: '振幅', visible: false, align: 'left' }, + // 行情 + { id: 'builtin:open', source: { type: 'builtin', key: 'open' }, label: '开盘', visible: false, align: 'left' }, + { id: 'builtin:high', source: { type: 'builtin', key: 'high' }, label: '最高', visible: false, align: 'left' }, + { id: 'builtin:low', source: { type: 'builtin', key: 'low' }, label: '最低', visible: false, align: 'left' }, + // 财务(数据来自 financials metrics 接口,默认隐藏;pe_ttm/pb 用 close 现算) + { id: 'builtin:eps', source: { type: 'builtin', key: 'eps' }, label: 'EPS', visible: false, align: 'left' }, + { id: 'builtin:bps', source: { type: 'builtin', key: 'bps' }, label: 'BPS', visible: false, align: 'left' }, + { id: 'builtin:roe', source: { type: 'builtin', key: 'roe' }, label: 'ROE', visible: false, align: 'left' }, + { id: 'builtin:pe_ttm', source: { type: 'builtin', key: 'pe_ttm' }, label: 'PE', visible: false, align: 'left' }, + { id: 'builtin:pb', source: { type: 'builtin', key: 'pb' }, label: 'PB', visible: false, align: 'left' }, + { id: 'builtin:gross_margin', source: { type: 'builtin', key: 'gross_margin' }, label: '毛利率', visible: false, align: 'left' }, + { id: 'builtin:net_margin', source: { type: 'builtin', key: 'net_margin' }, label: '净利率', visible: false, align: 'left' }, + { id: 'builtin:debt_ratio', source: { type: 'builtin', key: 'debt_ratio' }, label: '负债率', visible: false, align: 'left' }, + { id: 'builtin:revenue_yoy', source: { type: 'builtin', key: 'revenue_yoy' }, label: '营收增速', visible: false, align: 'left' }, + { id: 'builtin:net_income_yoy', source: { type: 'builtin', key: 'net_income_yoy' }, label: '净利增速', visible: false, align: 'left' }, +] + +export const INFO_GROUPS: ColumnGroup[] = [ + { id: 'scale', label: '规模', icon: '🏦', keys: ['market_cap', 'float_market_cap'] }, + { id: 'volume', label: '成交', icon: '📊', keys: ['turnover', 'volume', 'amplitude'] }, + { id: 'quote', label: '行情', icon: '📈', keys: ['open', 'high', 'low'] }, + { id: 'finance', label: '财务', icon: '📋', keys: ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'] }, +] + +// ===== localStorage 持久化 ===== + +/** 加载信息条指标配置:localStorage → 默认值,自动补齐新增默认项。 */ +export function loadInfoFields(): ColumnConfig[] { + const saved = storage.stockInfoBarFields.get([]) as ColumnConfig[] + if (saved.length === 0) return [...BUILTIN_INFO_FIELDS] + return mergeFields(saved, BUILTIN_INFO_FIELDS) +} + +/** 保存信息条指标配置到 localStorage。 */ +export function saveInfoFields(columns: ColumnConfig[]): void { + storage.stockInfoBarFields.set(serializeFields(columns)) +} + +/** 序列化(此处无 pinned/action 列,直接用底座默认实现)。 */ +function serializeFields(columns: ColumnConfig[]): ColumnConfig[] { + return serializeColumnsBase(columns) +} + +/** 从信息条字段配置中提取 ext 列参数(逗号分隔 config_id.field_name),用于 klineDaily 接口。 */ +export function buildInfoExtColumnsParam(columns: ColumnConfig[]): string { + return buildExtColumnsParamBase(columns) +} + +/** 合并用户保存的配置与默认配置。 */ +function mergeFields(saved: ColumnConfig[], defaults: ColumnConfig[]): ColumnConfig[] { + // 无固定列,传入空的 pinnedFirstIds 跳过「代码置顶」逻辑 + return mergeColumnsBase(saved, defaults, { pinnedFirstIds: [] }) +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index c5399fe..cbbe5f9 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -30,6 +30,9 @@ export const storage = { /** 自选列表列配置 */ watchlistColumns: kv('watchlist_columns'), + /** 个股日K信息条指标配置 */ + stockInfoBarFields: kv('stock_info_bar_fields'), + /** 策略结果列表列配置 */ screenerResultColumns: kv('screener_result_columns'),