feat: 股票详情页增加最新交易日PE/PB/ROE/换手率/市值等行情指标

- 后端tencent.py增加换手率/市盈率/市净率/总市值/流通市值字段提取
- 前端StockQuote接口新增对应字段类型定义
- 股票详情页增加"行情指标"卡片,展示PE/PB/ROE/换手率/总手/成交额/市值
- ROE取自财务分析数据的加权净资产收益率

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-07-08 19:27:23 +08:00
co-authored by Claude
parent 86535f5de2
commit 60eff4a955
3 changed files with 85 additions and 2 deletions
+13 -2
View File
@@ -142,10 +142,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
return None
text = resp.content.decode("gbk", errors="replace")
parts = parse_tencent_data(text)
if not parts or len(parts) < 38:
if not parts or len(parts) < 46:
return None
# 字段索引(1-based)1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
# 字段索引:1=名称, 3=当前价, 4=昨收, 5=今开, 6=成交量(手)
# 7=外盘, 8=内盘, 31=涨跌额, 32=涨跌幅%, 33=最高, 34=最低, 37=成交额(万)
# 38=换手率%, 39=市盈率, 43=总市值, 44=流通市值, 45=市净率
name = parts[1] or ""
current_price = float(parts[3]) if parts[3] else 0
yesterday_close = float(parts[4]) if parts[4] else 0
@@ -158,6 +159,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
change_pct = float(parts[32]) if parts[32] else 0
outer_disk = float(parts[7]) if parts[7] else 0
inner_disk = float(parts[8]) if parts[8] else 0
turnover_rate = float(parts[38]) if parts[38] else 0 # 换手率%
pe = float(parts[39]) if parts[39] else 0 # 市盈率
total_market_cap = float(parts[43]) if parts[43] else 0 # 总市值
circulating_market_cap = float(parts[44]) if parts[44] else 0 # 流通市值
pb = float(parts[45]) if parts[45] else 0 # 市净率
if not name or current_price == 0:
return None
@@ -186,6 +192,11 @@ async def fetch_quote(code: str) -> Optional[dict]:
"time": now.strftime("%H:%M:%S"),
"change": round(change, 2),
"changePercent": round(change_percent, 2),
"turnoverRate": round(turnover_rate, 2),
"pe": round(pe, 2) if pe else 0,
"pb": round(pb, 2) if pb else 0,
"totalMarketCap": round(total_market_cap, 2),
"circulatingMarketCap": round(circulating_market_cap, 2),
}
except Exception:
return None
+5
View File
@@ -18,6 +18,11 @@ export interface StockQuote {
time: string;
change: number;
changePercent: number;
turnoverRate: number; // 换手率%
pe: number; // 市盈率
pb: number; // 市净率
totalMarketCap: number; // 总市值
circulatingMarketCap: number; // 流通市值
}
export interface KLineData {
+67
View File
@@ -63,8 +63,14 @@ interface StockInfo {
addedPrice: number | null;
outerDisk: number; // 外盘(手)
innerDisk: number; // 内盘(手)
volume: number; // 成交量(股)
amount: number; // 成交额(元)
quoteDate: string; // 行情日期 YYYY-MM-DD
turnoverRate: number; // 换手率%
pe: number; // 市盈率
pb: number; // 市净率
totalMarketCap: number; // 总市值
circulatingMarketCap: number; // 流通市值
}
function StockDetail() {
@@ -155,6 +161,12 @@ function StockDetail() {
innerDisk: quote.innerDisk || 0,
amount: (quote.amount || 0) * 10000,
quoteDate: quote.date,
volume: quote.volume || 0,
turnoverRate: quote.turnoverRate ?? 0,
pe: quote.pe ?? 0,
pb: quote.pb ?? 0,
totalMarketCap: quote.totalMarketCap ?? 0,
circulatingMarketCap: quote.circulatingMarketCap ?? 0,
});
// 基础信息已就绪,结束主loading,先渲染页面框架
@@ -267,6 +279,14 @@ function StockDetail() {
negativeDays: negative,
};
}, [fundFlowSlice]);
// 从财务数据中提取最新ROE(加权净资产收益率)
const latestROE = useMemo(() => {
if (!financialData?.data?.length) return null;
const latest = financialData.data[0];
const roe = latest.indicators["加权净资产收益率"];
return typeof roe === "number" ? roe : null;
}, [financialData]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -653,6 +673,53 @@ function StockDetail() {
</Card>
</div>
{/* 行情指标:PE / PB / ROE / 换手率 / 总手 / 成交额 / 市值 */}
<Card className="mt-4 md:mt-6 shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<CardTitle className="text-base md:text-lg"></CardTitle>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1">(PE)</p>
<p className="text-lg font-semibold">{stockInfo.pe > 0 ? stockInfo.pe.toFixed(2) : '-'}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1">(PB)</p>
<p className="text-lg font-semibold">{stockInfo.pb > 0 ? stockInfo.pb.toFixed(2) : '-'}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1">ROE()</p>
<p className={`text-lg font-semibold ${latestROE !== null ? (latestROE > 0 ? 'text-red-500' : 'text-green-500') : ''}`}>
{latestROE !== null ? `${latestROE.toFixed(2)}%` : '加载中...'}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold">{stockInfo.turnoverRate > 0 ? `${stockInfo.turnoverRate}%` : '-'}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold">
{stockInfo.volume > 0 ? `${(stockInfo.volume / 100).toLocaleString()}` : '-'}
</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold">{formatMoney(stockInfo.amount)}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold">{stockInfo.totalMarketCap > 0 ? formatMoney(stockInfo.totalMarketCap) : '-'}</p>
</div>
<div className="text-center p-3 rounded-lg bg-muted/30">
<p className="text-xs text-muted-foreground mb-1"></p>
<p className="text-lg font-semibold">{stockInfo.circulatingMarketCap > 0 ? formatMoney(stockInfo.circulatingMarketCap) : '-'}</p>
</div>
</div>
</CardContent>
</Card>
{/* Daily Data Table - Independent from fund flow */}
{chartData.length > 0 && (
<Card className="mt-4 md:mt-6 shadow-lg">