feat: 个股日K信息条支持自定义指标(扩展数据/财务/单独显示)

- 信息条(StockInfoBar)行末加配置齿轮,复用自选列表抽屉自定义显示项
- 新增指标: 成交量/振幅/开高低 + 扩展数据(ext) + 财务(EPS/BPS/ROE/PE/PB等)
- 后端 klineDaily 新增 ext_columns 参数,按 symbol JOIN 扩展数据
- 财务数据前端直拉 useFinancialMetrics, PE/PB 用现价现算
- 每列可设单独显示(standalone),独占一行渲染
- ext 标签支持 maxTags 截断 + 点击 +N 同页展开/收起
- 修复重开弹窗信息条消失(symbol 重置与子组件 onDataChange 竞态)
- 修复 ext 勾选不显示(query key 未含 extColumns 导致缓存命中)
- 配置按钮左侧加监控通知图标占位(BellRing)
- ListColumnCustomizer 新增 showExtColumns/showStandaloneToggle prop
This commit is contained in:
shy3130
2026-06-20 14:42:54 +08:00
parent f82b02121e
commit 358c04e916
11 changed files with 478 additions and 39 deletions
+72 -2
View File
@@ -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]:
@@ -55,6 +55,8 @@ export interface StockInfo {
name?: string
total_shares?: number
float_shares?: number
/** 扩展数据(key: configId__fieldName),来自 klineDaily 的 ext_columns */
ext?: Record<string, unknown>
}
/** 子图定义 */
@@ -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}
</span>
{showStandaloneToggle && (
<button
onClick={() => onToggleStandalone?.(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary opacity-0 group-hover:opacity-100'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
{hasConfig && (
<button
onClick={() => onConfig(configOpen ? null : col.id)}
@@ -112,11 +131,13 @@ export function ListColumnCustomizer({
builtinSectionLabel = '内置列',
extColumnAlign = 'center',
extFieldFilter,
showExtColumns = true,
showStandaloneToggle = false,
}: ListColumnCustomizerProps) {
const extSchema = useQuery({
queryKey: QK.extDataSchemaAll,
queryFn: api.extDataSchemaAll,
enabled: open,
enabled: open && showExtColumns,
staleTime: 60_000,
})
@@ -142,6 +163,12 @@ export function ListColumnCustomizer({
))
}, [columns, onChange])
const toggleStandalone = useCallback((colId: string) => {
onChange(columns.map(c =>
c.id === colId ? { ...c, standalone: !c.standalone } : c
))
}, [columns, onChange])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -513,14 +540,28 @@ export function ListColumnCustomizer({
)
const renderBuiltinRow = (col: ColumnConfig) => (
<button
<div
key={col.id}
onClick={() => toggleVisible(col.id)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded hover:bg-elevated/50 text-left group transition-colors"
>
<button onClick={() => toggleVisible(col.id)} className="flex items-center gap-2 flex-1 min-w-0">
{renderCheckbox(col.visible)}
<span className={`flex-1 text-xs truncate ${col.visible ? 'text-foreground' : 'text-muted'}`}>{col.label}</span>
</button>
{showStandaloneToggle && col.visible && (
<button
onClick={() => toggleStandalone(col.id)}
className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[10px] transition-colors shrink-0 ${
col.standalone
? 'text-accent bg-accent/10'
: 'text-muted hover:text-secondary'
}`}
title={col.standalone ? '取消单独显示' : '单独一行显示'}
>
{col.standalone ? '单独' : '单行'}
</button>
)}
</div>
)
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}
/>
))}
</SortableContext>
@@ -650,7 +693,7 @@ export function ListColumnCustomizer({
})}
</div>
{extTables.length > 0 && (
{showExtColumns && extTables.length > 0 && (
<div className="pt-1 border-t border-border mt-1">
<div className="flex items-center gap-1.5 px-1 py-1.5">
<Database className="h-3 w-3 text-accent/70" />
@@ -700,7 +743,7 @@ export function ListColumnCustomizer({
</div>
)}
{extTables.length === 0 && extSchema.isSuccess && (
{showExtColumns && extTables.length === 0 && extSchema.isSuccess && (
<div className="text-xs text-muted text-center py-4">
</div>
+6 -2
View File
@@ -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<string[]>(['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,
})
+210 -15
View File
@@ -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<string, unknown> }
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 <span className="text-muted"></span>
}
if (typeof val === 'number') {
const displayVal = Number.isInteger(val) ? fmtPrice(val, 0) : fmtPrice(val)
return <span className="tabular-nums">{displayVal}</span>
}
if (typeof val === 'boolean') {
return <span className={val ? 'text-bull' : 'text-muted'}>{val ? '是' : '否'}</span>
}
const str = String(val)
// 纯文本模式
if (col.extDisplay?.displayMode === 'text') {
return <span>{str}</span>
}
// 标签模式(默认):按分隔符拆成小标签
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 <span className="text-muted"></span>
// 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 (
<span className="inline-flex flex-wrap items-center gap-0.5">
{shown.map((tag, i) => (
<span key={i} className="inline-block px-1 rounded text-[10px] leading-tight text-yellow-500 bg-yellow-500/10">
{tag}
</span>
))}
{!showAll && overflow > 0 && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-accent bg-accent/10 hover:bg-accent/20 transition-colors"
>
+{overflow}
</button>
)}
{showAll && maxTags > 0 && tags.length > maxTags && (
<button
onClick={onToggle}
className="inline-block px-1 rounded text-[10px] leading-tight text-muted hover:text-foreground transition-colors"
>
</button>
)}
</span>
)
}
export function StockInfoBar({ symbol, name, stockInfo, rows, fields, onFieldsChange, financialMetrics }: Props) {
// 弹窗开关:纯本地状态,与数据/配置无关,放早期 return 之前
const [customizerOpen, setCustomizerOpen] = useState(false)
// ext 标签展开状态:按 symbol::colId,切股/切字段时互不干扰
const [expandedExt, setExpandedExt] = useState<Set<string>>(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 (
<span key={f.id} className="inline-flex items-center gap-1">
<span>{f.label}</span>
<span className="text-secondary">
{renderExtInline(val, f, expandedExt.has(cellKey), () => toggleExtExpand(cellKey))}
</span>
</span>
)
}
// builtin
const value = computeBuiltinValue(f.source.type === 'builtin' ? f.source.key : '')
if (value == null) return null
return (
<span key={f.id}>
{f.label} <span className="text-secondary">{value}</span>
</span>
)
}
return (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
@@ -47,20 +209,53 @@ export function StockInfoBar({ symbol, name, stockInfo, rows }: Props) {
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:监控通知 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
<button
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="监控通知(开发中)"
>
<BellRing className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setCustomizerOpen(true)}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
title="自定义信息条"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Row 2: market cap, float market cap, turnover rate */}
<div className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
{marketCap != null && (
<span> <span className="text-secondary">{fmtBigNum(marketCap)}</span></span>
)}
{floatMarketCap != null && (
<span> <span className="text-secondary">{fmtBigNum(floatMarketCap)}</span></span>
)}
{turnoverRate != null && (
<span> <span className="text-secondary">{turnoverRate.toFixed(2)}%</span></span>
)}
{/* Row 2: 普通指标(builtin + ext,共一行 flex-wrap */}
{inlineFields.length > 0 && (
<div className="flex items-center gap-x-4 gap-y-1 text-[11px] flex-wrap text-muted">
{inlineFields.map(renderField)}
</div>
)}
{/* 单独显示的指标:各占一行 */}
{standaloneFields.map(f => {
const node = renderField(f)
if (node == null) return null
return (
<div key={f.id} className="flex items-center gap-x-4 text-[11px] flex-wrap text-muted">
{node}
</div>
)
})}
<ListColumnCustomizer
columns={fields}
groups={INFO_GROUPS}
onChange={onFieldsChange}
open={customizerOpen}
onClose={() => setCustomizerOpen(false)}
title="信息条指标"
builtinSectionLabel="可选指标"
extColumnAlign="left"
showStandaloneToggle
/>
</div>
)
}
+39 -3
View File
@@ -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<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
const [fields, setFields] = useState<ColumnConfig[]>(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<string | null>(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 (
<div className={className}>
<StockInfoBar
@@ -80,6 +112,9 @@ export function StockPanel({
name={dailyResult?.name}
stockInfo={stockInfo}
rows={rawRows}
fields={fields}
onFieldsChange={handleFieldsChange}
financialMetrics={financialMetrics}
/>
<div className="flex gap-3 items-start">
@@ -97,6 +132,7 @@ export function StockPanel({
onDateClick={handleDateClick}
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
/>
{showIntraday && selectedDate && (
+5 -4
View File
@@ -697,17 +697,18 @@ export const api = {
redetectCapabilities: () =>
request<CapabilitiesResponse>('/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<string, unknown> }
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<string, KlineRow[]> }>('/api/kline/daily-batch', {
+4 -1
View File
@@ -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 中的括号后缀
+2 -2
View File
@@ -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) =>
+82
View File
@@ -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: [] })
}
+3
View File
@@ -30,6 +30,9 @@ export const storage = {
/** 自选列表列配置 */
watchlistColumns: kv<unknown[]>('watchlist_columns'),
/** 个股日K信息条指标配置 */
stockInfoBarFields: kv<unknown[]>('stock_info_bar_fields'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),