FA3-Datafetch/frontend/src/components/bloomberg-view.tsx
2026-01-12 09:33:52 +08:00

584 lines
26 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.

"use client"
import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Loader2, DollarSign, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui/button"
import { getFinancialData } from "@/lib/api"
import { formatNumber, formatLargeNumber, formatDate } from "@/lib/formatters"
import type { FinancialDataResponse } from "@/lib/types"
interface BloombergViewProps {
companyId: number
onBack?: () => void
selectedCurrency?: string
userMarket?: string
}
export function BloombergView({ companyId, onBack, selectedCurrency = "Auto", userMarket }: BloombergViewProps) {
const [data, setData] = useState<FinancialDataResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const loadData = async () => {
setLoading(true)
setError("")
try {
console.log("Fetching Bloomberg data for company:", companyId)
const result = await getFinancialData(companyId, "Bloomberg")
console.log("Bloomberg data result:", result)
setData(result)
} catch (err: any) {
console.error("Bloomberg fetch error:", err)
setError(err.message || "加载 Bloomberg 数据失败")
} finally {
setLoading(false)
}
}
useEffect(() => {
loadData()
}, [companyId])
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground"> Bloomberg ...</p>
</div>
)
}
if (error) {
return (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="text-center text-destructive space-y-4">
<p>{error}</p>
<Button variant="outline" onClick={loadData}></Button>
</div>
</CardContent>
</Card>
)
}
if (!data) return null
// 如果后端提供了统一数据字段,直接使用
const mergedData = data.unified_data || []
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold flex items-center gap-2">
<DollarSign className="w-6 h-6" />
Bloomberg
</h2>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
<RawDataTable title="财务数据总表" data={mergedData} selectedCurrency={selectedCurrency} userMarket={userMarket} />
</div>
)
}
function RawDataTable({ data, title, selectedCurrency = "Auto", userMarket }: { data: any[], title: string, selectedCurrency?: string, userMarket?: string }) {
if (!data || data.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center text-muted-foreground py-8"></div>
</CardContent>
</Card>
)
}
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency) - DO THIS FIRST!
// ---------------------------------------------------------------------------
// Determine Target Currency Strategy
let targetCurrency = selectedCurrency
if (targetCurrency === "Auto" && userMarket) {
const market = userMarket.toUpperCase()
if (market.includes("JP")) targetCurrency = "JPY"
else if (market.includes("VN")) targetCurrency = "VND"
else if (market.includes("CN")) targetCurrency = "CNY"
else if (market.includes("HK")) targetCurrency = "HKD"
else targetCurrency = "USD"
}
const filteredRows = data.filter(row => {
// Basic Validity Check
if (!row || row.revenue === null || row.revenue === undefined) return false
// Currency Filtering
// Case 1: Specific Currency Selected (or resolved from Auto)
if (targetCurrency !== "Auto") {
if (row.currency && row.currency !== targetCurrency) {
return false
}
}
// Case 2: Pure Auto (Fallback if no userMarket)
else if (selectedCurrency === "Auto") {
if (data.length > 0 && data[0].currency && row.currency && row.currency !== data[0].currency) {
return false
}
}
return true
})
// ---------------------------------------------------------------------------
// 2. Extract Indicators (From Filtered Data ONLY)
// ---------------------------------------------------------------------------
// 提取所有指标键值 (排除元数据)
const excludeKeys = ['id', 'company_code', 'code', 'symbol', 'market', 'update_date', 'create_time', 'end_date', 'ts_code']
// 从合并后的数据中收集所有可能的指标
const allIndicators = new Set<string>()
filteredRows.forEach(row => {
Object.keys(row).forEach(k => {
if (!excludeKeys.includes(k)) {
allIndicators.add(k)
}
})
})
// 对指标进行逻辑排序/分组
const priorityOrder = [
'currency',
'__SECTION_MAIN__',
'roe', 'roce', 'roa',
'gross_margin', 'net_profit_margin', 'ebitda_margin',
'revenue', 'revenue_growth',
'net_income', 'netincome_growth',
'cash_from_operating', 'capital_expenditure', 'free_cash_flow',
'dividends_paid', 'dividend_payout_ratio', 'repurchase',
'total_assets', 'equity', 'goodwill',
'__SECTION_EXPENSE__',
'selling&marketing', 'general&admin', 'sg&a', 'r&d',
'depreciation', 'tax_rate',
'__SECTION_ASSET__',
'cash', 'inventory', 'accounts&notes_receivable', 'prepaid',
'property_plant&equipment', 'lt_investment',
'accounts_payable', 'st_defer_rev',
'st_debt', 'lt_debt', 'total_debt_ratio',
'__SECTION_TURNOVER__',
'inventory_days', 'days_sales_outstanding', 'payables_days',
'net_fixed_asset_turnover', 'asset_turnover',
'__SECTION_EFFICIENCY__',
'employee', 'num_of_employees',
'__SECTION_MARKET__',
'last_price', 'market_cap', 'pe', 'pb',
'dividend_yield', 'shareholders'
]
// Ensure section header is in the list to be sorted
const sectionKeys = [
'__SECTION_MAIN__', '__SECTION_EXPENSE__', '__SECTION_ASSET__',
'__SECTION_TURNOVER__', '__SECTION_EFFICIENCY__', '__SECTION_MARKET__'
]
sectionKeys.forEach(key => {
if (!allIndicators.has(key)) {
allIndicators.add(key)
}
})
const sortedIndicators = Array.from(allIndicators).sort((a, b) => {
const idxA = priorityOrder.indexOf(a)
const idxB = priorityOrder.indexOf(b)
// Items in priority list come first, sorted by their order in the list
if (idxA !== -1 && idxB !== -1) return idxA - idxB
// Items in priority list come before items not in list
if (idxA !== -1) return -1
if (idxB !== -1) return 1
// Remaining items sorted alphabetically
return a.localeCompare(b)
})
// ---------------------------------------------------------------------------
// 1. Filter Data Rows (Handle Multi-Currency)
// ---------------------------------------------------------------------------
// We must filter BEFORE creating the Map, because multiple rows might exist
// for the same date (e.g. 2023 JPY and 2023 USD).
// The Map key is 'end_date', so it can only hold one currency's data per date.
// ---------------------------------------------------------------------------
// 2. Build Data Structures from Filtered Rows
// ---------------------------------------------------------------------------
// 构建查找表: Map<DateString, RowData>
const dataMap = new Map()
filteredRows.forEach(row => {
dataMap.set(row.end_date, row)
})
// 提取日期列表
const dates = Array.from(new Set(filteredRows.map(row => row.end_date)))
.sort((a, b) => new Date(b).getTime() - new Date(a).getTime())
// Tooltip State
const [tooltip, setTooltip] = useState<{ show: boolean, x: number, y: number, text: string }>({
show: false, x: 0, y: 0, text: ''
})
const handleMouseMove = (e: React.MouseEvent, text: string) => {
setTooltip({
show: true,
x: e.clientX,
y: e.clientY,
text
})
}
const handleMouseLeave = () => {
setTooltip(prev => ({ ...prev, show: false }))
}
// 获取第一行数据的货币单位 (假设一致)
const currency = data[0]?.currency || 'CNY'
return (
<div className="relative">
{tooltip.show && (
<div
className="fixed z-50 px-2 py-1 text-xs text-white bg-black/90 rounded pointer-events-none whitespace-nowrap"
style={{
left: tooltip.x + 10,
top: tooltip.y + 10
}}
>
{tooltip.text}
</div>
)}
<div className="rounded-md border">
<table className="w-full caption-bottom text-sm">
<TableHeader>
<TableRow>
<TableHead className="w-[200px] sticky left-0 top-0 bg-background z-50 border-r border-b shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)]">
<div className="flex flex-col gap-1 py-1">
<span className="font-bold text-foreground"> / </span>
<span className="text-xs font-normal text-muted-foreground">(单位: 亿)</span>
</div>
</TableHead>
{dates.map(date => (
<TableHead key={date} className="text-right min-w-[120px] px-4 font-mono sticky top-0 bg-background z-40 border-b">
{formatDate(date)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{sortedIndicators.map(indicator => {
if (indicator === '__SECTION_MAIN__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_EXPENSE__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_ASSET__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_TURNOVER__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_EFFICIENCY__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
if (indicator === '__SECTION_MARKET__') {
return (
<TableRow key={indicator} className="bg-muted">
<TableCell
className="font-bold sticky left-0 bg-muted z-10 border-r py-1"
colSpan={1}
>
</TableCell>
<TableCell colSpan={dates.length} className="bg-muted p-0" />
</TableRow>
)
}
return (
<TableRow key={indicator} className="hover:bg-purple-100 dark:hover:bg-purple-900/40">
<TableCell
className={`font-medium sticky left-0 bg-background z-10 border-r shadow-[2px_0_5px_-2px_rgba(0,0,0,0.1)] cursor-help transition-colors hover:bg-muted ${['revenue_growth', 'netincome_growth'].includes(indicator) ? 'text-blue-600 italic dark:text-blue-400' : ''
}`}
onMouseMove={(e) => handleMouseMove(e, indicator)}
onMouseLeave={handleMouseLeave}
>
<span>{formatColumnName(indicator)}</span>
</TableCell>
{dates.map(date => {
const row = dataMap.get(date)
const value = row ? row[indicator] : null
let highlightClass = ''
let textStyle = ''
if (value !== null && value !== undefined) {
const numVal = typeof value === 'string' ? parseFloat(value) : value
if (typeof numVal === 'number' && !isNaN(numVal)) {
if (indicator === 'roe' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'gross_margin' && numVal > 40) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'net_profit_margin' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'roce' && numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'roa' && numVal > 10) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (indicator === 'revenue_growth') {
if (numVal > 15) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
}
else if (indicator === 'netincome_growth') {
if (numVal > 20) highlightClass = 'bg-green-100 dark:bg-green-900/40'
else if (numVal < 0) highlightClass = 'bg-red-100 dark:bg-red-900/40'
}
// Dynamic text style based on value for growth rates
if (['revenue_growth', 'netincome_growth'].includes(indicator)) {
if (numVal < 0) {
textStyle = 'text-red-600 italic font-bold dark:text-red-400'
} else {
textStyle = 'text-blue-600 italic dark:text-blue-400'
}
}
}
}
// Fallback for text style if value is missing/invalid but indicator is growth rate
if (textStyle === '' && ['revenue_growth', 'netincome_growth'].includes(indicator)) {
textStyle = 'text-blue-600 italic dark:text-blue-400'
}
return (
<TableCell key={date} className={`text-right px-4 font-mono ${highlightClass} ${textStyle}`}>
{formatCellValue(indicator, value)}
</TableCell>
)
})}
</TableRow>
)
})}
</TableBody>
</table>
</div>
</div>
)
}
// 复用之前的 formatting 逻辑
function formatColumnName(column: string): string {
const nameMap: Record<string, string> = {
// --- 核心利润表 ---
'revenue': '营业收入',
'net_income': '净利润',
'operating_profit': '营业利润',
'gross_margin': '毛利率',
'net_profit_margin': '净利率',
'ebitda_margin': 'EBITDA利润率',
'sg&a': '销售管理费用(SG&A)',
'selling&marketing': '销售费用',
'general&admin': '管理费用',
'ga_exp': '管理费用', // Alias
'rd_exp': '研发费用',
'r&d': '研发费用',
'depreciation': '折旧',
'tax_rate': '有效税率',
'dividends': '分红',
'dividend_payout_ratio': '分红支付率',
'netincome_growth': '净利润增长率',
'revenue_growth': '营收增长率',
// --- 资产负债表 ---
'total_assets': '总资产',
'total_liabilities': '总负债',
'shareholders_equity': '净资产', // Alias
'total_equity': '净资产', // Alias
'equity': '净资产', // Real Bloomberg Key
'cash': '现金',
'inventory': '存货',
'accounts&notes_receivable': '应收账款及票据',
'receivables': '应收账款',
'accounts_payable': '应付账款',
'prepayment': '预付款项',
'prepaid': '预付款项',
'fixed_assets': '固定资产',
'property_plant&equipment': '固定资产(PP&E)',
'long_term_investments': '长期投资',
'lt_investment': '长期投资',
'short_term_debt': '短期借款',
'st_debt': '短期借款',
'long_term_debt': '长期借款',
'lt_debt': '长期借款',
'deferred_revenue': '递延收入',
'st_defer_rev': '短期递延收入',
'goodwill': '商誉',
'total_debt_ratio': '总债务比率',
// --- 现金流量表 ---
'cash_from_operating': '经营现金流',
'ocf': '经营现金流', // Alias
'capital_expenditure': '资本支出',
'capex': '资本支出', // Alias
'free_cash_flow': '自由现金流',
'fcf': '自由现金流', // Alias
'dividends_paid': '支付股息',
'repurchase': '股份回购',
// --- 运营效率/周转 ---
'asset_turnover': '总资产周转率',
'net_fixed_asset_turnover': '固定资产周转率', // Correct Bloomberg Key
'net_fixed_asset_turn': '固定资产周转率', // Alias just in case
'inventory_days': '存货周转天数',
'invent_days': '存货周转天数',
'days_sales_outstanding': '应收账款周转天数',
'payables_days': '应付账款周转天数',
'employee': '员工人数',
'num_of_employees': '员工人数',
// --- 估值与市场 ---
'pe': '市盈率(PE)',
'pe_ratio': '市盈率(PE)',
'pb': '市净率(PB)',
'pb_ratio': '市净率(PB)',
'market_cap': '市值',
'price': '最新股价',
'last_price': '最新股价',
'roe': 'ROE',
'roa': 'ROA',
'roce': 'ROCE',
'dividend_yield': '股息率',
'shareholders': '股东人数',
'company_name': '公司名称',
'ipo_date': '上市日期',
'rev_abroad': '海外收入占比',
'currency': '货币',
}
return nameMap[column.toLowerCase()] || column
}
function formatCellValue(column: string, value: any): string {
if (value === null || value === undefined) return '-'
// 尝试解析文本数字
let numVal = value
if (typeof value === 'string') {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
numVal = parsed
}
}
if (typeof numVal === 'number') {
const lowerCol = column.toLowerCase()
// Special scaling for tax_rate
if (lowerCol === 'tax_rate') {
numVal = numVal * 100
}
const isRatio = ['roe', 'roce', 'roa', 'gross_margin', 'net_profit_margin', 'ebitda_margin', 'dividend_yield', 'rev_abroad', 'tax_rate', 'revenue_growth', 'netincome_growth'].includes(lowerCol)
// Scale huge monetary values to "Yi" (100 Million)
// Bloomberg raw data is in Millions.
// Millions -> Yi (100 Millions) = divide by 100.
const isMoney = [
'revenue', 'net_income', 'operating_profit',
'total_assets', 'total_liabilities', 'equity', 'shareholders_equity', 'total_equity',
'cash', 'inventory', 'accounts&notes_receivable', 'receivables', 'accounts_payable', 'prepaid', 'prepayment',
'property_plant&equipment', 'fixed_assets', 'lt_investment', 'long_term_investments',
'st_debt', 'short_term_debt', 'lt_debt', 'long_term_debt', 'st_defer_rev', 'deferred_revenue', 'goodwill',
'cash_from_operating', 'capital_expenditure', 'capex', 'free_cash_flow', 'fcf',
'dividends_paid', 'repurchase',
'sg&a', 'selling&marketing', 'general&admin', 'r&d', 'depreciation',
'market_cap'
].includes(lowerCol)
if (isMoney) {
numVal = numVal / 100
}
if (isRatio) {
return numVal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}) + '%'
}
// 如果大于 1000不保留小数否则保留两位小数
const isLarge = Math.abs(numVal) > 1000
return numVal.toLocaleString('en-US', {
minimumFractionDigits: isLarge ? 0 : 2,
maximumFractionDigits: isLarge ? 0 : 2
})
}
return String(value)
}