Files
stock/local/frontend/src/lib/analysis-adapter.ts
T
2026-07-04 16:32:48 +08:00

342 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 概念/行业分析 — 数据适配层
*
* 处理两种扩展数据结构:
* - 结构 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,
}
}