aad34202f1
Co-Authored-By: Claude <noreply@anthropic.com>
342 lines
9.6 KiB
TypeScript
342 lines
9.6 KiB
TypeScript
/**
|
||
* 概念/行业分析 — 数据适配层
|
||
*
|
||
* 处理两种扩展数据结构:
|
||
* - 结构 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<string, number | null>
|
||
}
|
||
|
||
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<string, any>[],
|
||
dimensionField: string,
|
||
numericFields: string[],
|
||
): DimensionGroup[] {
|
||
const map = new Map<string, StockRow[]>()
|
||
|
||
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<string, any>[],
|
||
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<string, any>
|
||
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<string, number | null> {
|
||
const result: Record<string, number | null> = {}
|
||
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<string, QuoteMap> {
|
||
const map = new Map<string, QuoteMap>()
|
||
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<string, QuoteMap>,
|
||
): {
|
||
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,
|
||
}
|
||
}
|