/** * 概念/行业分析 — 数据适配层 * * 处理两种扩展数据结构: * - 结构 A(个股维度):每行一只股票,维度字段(如 concept)存该股票所属的概念/行业 * - 结构 B(板块维度):每行一个概念/行业,带成分股列表(如 constituents: [...]) * * 两种结构统一输出为 DimensionGroup[],供页面组件消费。 */ import type { ExtDataConfig, ExtDataField, ExtDataRowsResult } from '@/lib/api' // ===== 公共类型 ===== export interface StockRow { symbol: string code?: string name?: string [key: string]: unknown } export interface DimensionGroup { /** 维度名称(概念名/行业名) */ key: string /** 成分股数量 */ count: number /** 成分股原始行 */ stocks: StockRow[] /** 聚合指标(如涨跌幅均值等) */ metrics: Record } export interface ResolvedDimension { /** 是否成功解析 */ ok: boolean /** 数据结构类型 */ structure: 'per_stock' | 'per_dimension' | 'unknown' /** 维度字段名 */ dimensionField: string /** 所有解析出的分组 */ groups: DimensionGroup[] /** 原始全部行(结构 A 下为原始行,结构 B 下展平后的全部成分股) */ allStocks: StockRow[] /** 解析提示 */ hint?: string } // ===== 结构探测 ===== const SEPARATORS = /[、,,;;|/\s]+/ const CONSTITUENT_KEYS = [ 'constituents', '成分股', 'stocks', 'members', 'codes', 'list', 'symbol_list', 'stock_list', 'member_list', ] const DIMENSION_NAME_KEYS = [ 'name', '概念名称', '概念', '行业名称', '行业', '板块名称', '板块', 'concept', 'industry', 'sector', 'theme', 'title', 'label', ] /** 检测行是否是"板块维度"结构(含成分股列表字段) */ function detectConstituentField(fields: ExtDataField[]): string | null { return fields.find(f => CONSTITUENT_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k)) )?.name ?? null } /** 检测维度名称字段 */ function detectDimensionNameField(fields: ExtDataField[]): string | null { return fields.find(f => DIMENSION_NAME_KEYS.some(k => f.name.toLowerCase() === k.toLowerCase() || f.label?.includes(k)) )?.name ?? null } /** 从候选名中选取最佳维度字段(结构 A) */ export function pickDimensionField( fields: ExtDataField[], candidates: string[], ): string { const nonMeta = fields.filter(f => !['symbol', 'code', 'name', '股票简称', '股票代码', 'date'].includes(f.name) ) for (const c of candidates) { const m = nonMeta.find(f => f.name.toLowerCase().includes(c.toLowerCase()) || f.label?.toLowerCase().includes(c.toLowerCase()) ) if (m) return m.name } // 回退:第一个非数值字段 return nonMeta.find(f => f.dtype !== 'int' && f.dtype !== 'float')?.name ?? nonMeta[0]?.name ?? '' } /** 判断字段是否为数值类型 */ function isNumericField(f: ExtDataField): boolean { return f.dtype === 'int' || f.dtype === 'float' } // ===== 结构 A 解析:个股维度 ===== function parsePerStock( rows: Record[], dimensionField: string, numericFields: string[], ): DimensionGroup[] { const map = new Map() for (const row of rows) { const raw = row[dimensionField] if (raw == null) continue const text = String(raw).trim() if (!text) continue // 支持多值分隔(如 "人工智能,芯片,5G") const values = text.split(SEPARATORS).map(s => s.trim()).filter(Boolean) const stock: StockRow = { ...row, symbol: row.symbol ?? row.code ?? '' } for (const v of values) { const list = map.get(v) ?? [] list.push(stock) map.set(v, list) } } return [...map.entries()] .map(([key, stocks]) => ({ key, count: stocks.length, stocks, metrics: computeMetrics(stocks, numericFields), })) .sort((a, b) => b.count - a.count) } // ===== 结构 B 解析:板块维度 ===== function parsePerDimension( rows: Record[], constituentField: string, nameField: string, numericFields: string[], ): DimensionGroup[] { const allStocks: StockRow[] = [] const groups = rows.map(row => { const key = String(row[nameField] ?? row[constituentField] ?? '').trim() if (!key) return null // 成分股可能是字符串数组、对象数组、逗号分隔字符串 const rawList = row[constituentField] const stocks = parseConstituents(rawList) stocks.forEach(s => { if (s.symbol) allStocks.push(s) }) // 维度自身的数值指标也保留 const metrics = computeMetrics(stocks, numericFields) // 补上行级别的数值 for (const f of numericFields) { if (typeof row[f] === 'number') { metrics[`__dim_${f}`] = row[f] } } return { key, count: stocks.length, stocks, metrics } as DimensionGroup }).filter((g): g is DimensionGroup => g !== null && g.key !== '') return groups.sort((a, b) => b.count - a.count) } /** 解析成分股字段(支持多种格式) */ function parseConstituents(raw: unknown): StockRow[] { if (raw == null) return [] if (typeof raw === 'string') { // 逗号/分隔符分隔的股票代码字符串 return raw.split(SEPARATORS).map(s => s.trim()).filter(Boolean).map(s => ({ symbol: normalizeSymbol(s), code: s, })) } if (Array.isArray(raw)) { return raw.map(item => { if (typeof item === 'string') { return { symbol: normalizeSymbol(item), code: item } } if (typeof item === 'object' && item !== null) { const obj = item as Record return { symbol: obj.symbol ?? obj.code ?? obj.股票代码 ?? '', code: obj.code ?? obj.symbol ?? '', name: obj.name ?? obj.股票简称 ?? obj.名称 ?? '', ...obj, } } return { symbol: String(item) } }) } return [] } function normalizeSymbol(s: string): string { // 尝试补全为 6 位代码 if (/^\d{6}$/.test(s)) return s return s } // ===== 聚合指标计算 ===== function computeMetrics( stocks: StockRow[], numericFields: string[], ): Record { const result: Record = {} for (const f of numericFields) { const vals = stocks .map(s => s[f]) .filter((v): v is number => typeof v === 'number' && Number.isFinite(v)) if (vals.length === 0) { result[f] = null } else { result[f] = vals.reduce((a, b) => a + b, 0) / vals.length } } return result } // ===== 主入口:自动探测 + 解析 ===== export function resolveDimension( data: ExtDataRowsResult | null | undefined, config: ExtDataConfig | null | undefined, candidateFields: string[], ): ResolvedDimension { if (!data || !config || !data.rows.length) { return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: [] } } const fields = data.fields ?? config.fields const rows = data.rows const numericFields = fields.filter(f => isNumericField(f)).map(f => f.name) // 先检测是否为结构 B(板块维度) const constituentField = detectConstituentField(fields) if (constituentField) { const nameField = detectDimensionNameField(fields) ?? 'name' const groups = parsePerDimension(rows, constituentField, nameField, numericFields) const allStocks = groups.flatMap(g => g.stocks) return { ok: true, structure: 'per_dimension', dimensionField: nameField, groups, allStocks, hint: `检测到板块维度结构(成分股字段: ${constituentField})`, } } // 结构 A(个股维度) const dimensionField = pickDimensionField(fields, candidateFields) if (!dimensionField) { return { ok: false, structure: 'unknown', dimensionField: '', groups: [], allStocks: rows as StockRow[], hint: '未找到合适的维度字段', } } const groups = parsePerStock(rows, dimensionField, numericFields) const allStocks = rows as StockRow[] return { ok: true, structure: 'per_stock', dimensionField, groups, allStocks, hint: `按 ${dimensionField} 分组,共 ${groups.length} 个维度`, } } // ===== 行情数据关联 ===== export interface QuoteMap { symbol: string price?: number pct?: number change_pct?: number name?: string [key: string]: unknown } /** 构建 symbol → quote 的快速查找 */ export function buildQuoteMap(quotes: QuoteMap[]): Map { const map = new Map() for (const q of quotes) { if (q.symbol) map.set(q.symbol, q) // 也用纯数字代码做索引 const code = q.symbol?.replace(/\.\w+$/, '') if (code) map.set(code, q) } return map } /** 为分组计算行情聚合指标 */ export function computeQuoteMetrics( stocks: StockRow[], quoteMap: Map, ): { avgPct: number | null upCount: number downCount: number flatCount: number totalVolume: number } { let up = 0, down = 0, flat = 0, totalVol = 0 let sumPct = 0, countPct = 0 for (const s of stocks) { const sym = String(s.symbol ?? '') const q = quoteMap.get(sym) ?? quoteMap.get(sym.replace(/\.\w+$/, '')) if (!q) continue const pct = q.pct ?? q.change_pct if (pct != null && typeof pct === 'number' && Number.isFinite(pct)) { sumPct += pct countPct++ if (pct > 0) up++ else if (pct < 0) down++ else flat++ } totalVol += (typeof q.price === 'number' ? 1 : 0) // 简化计数 } return { avgPct: countPct > 0 ? sumPct / countPct : null, upCount: up, downCount: down, flatCount: flat, totalVolume: totalVol, } }