Skip to content

Commit 8298bd0

Browse files
v0.13.1: 修两个测试中发现的 bug(利润质量字段 + 行业 ROE 映射)
实测 v0.10-v0.13 时发现两个未渲染的报告章节,均为数据接口适配问题: bug #1:#52 利润质量深度分解节空白 - 现象:报告"应收账款 / 合同负债"节未渲染(fp.accounts_receivable 等都是 None) - 根因:stock_financial_abstract 返回的"常用指标"DataFrame 只含 70 个指标 (主要是周转率、ROE、毛利率等),不含资产负债表科目本身 - 修复:新增 _a_balance_sheet_enrich 用 stock_balance_sheet_by_report_em 二次拉取 按 REPORT_DATE (YYYY-MM-DD) 匹配 period (YYYYMMDD),填充 ACCOUNTS_RECE / CONTRACT_LIAB / ADVANCE_RECEIVABLES - 验证:茅台 2025 应收 ¥260 万(极低,强势)+ 合同负债 ¥80 亿(经销商打款先行) bug #2:#51 行业 ROE 横截面分位 0 peer - 现象:报告显示"同行业有效样本不足 5 只(实际 0)" - 根因:baostock industry 字段是证监会大类('C15酒、饮料和精制茶制造业'), 与 INDUSTRYCSRC1 的细分名"白酒"完全不匹配,原 industry[:2] 关键词不命中 - 修复:新增 _BAOSTOCK_INDUSTRY_MAP(20+ 行业)+ _map_to_baostock_industry 白酒 → '酒、饮料';煤炭 → '煤炭开采';汽车 → '汽车制造';银行 → '货币金融' 等 - 验证:茅台 ROE 30% 在白酒 29 同行中排第 4 名,分位 89.7% 测试:76/76 pass。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8dad975 commit 8298bd0

3 files changed

Lines changed: 102 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "stockwise"
3-
version = "0.13.0"
3+
version = "0.13.1"
44
description = "巴菲特/林奇范式 A 股 + 港股价值投资分析工具:Web UI + HKEx 链接 + 主营构成 + mkdocs 文档"
55
license = { text = "MIT" }
66
requires-python = ">=3.10"

stockwise/data/fetcher.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,46 @@ def _a_sina_daily(code: str) -> Optional[dict]:
244244
}
245245

246246

247+
def _a_balance_sheet_enrich(code: str, fin: Financials) -> None:
248+
"""v0.11 #52 fix:从东财资产负债表接口拉应收账款 / 合同负债填充到 fin。
249+
250+
stock_financial_abstract 返回的"常用指标"不含资产负债表科目,需要
251+
stock_balance_sheet_by_report_em(英文列名:ACCOUNTS_RECE / CONTRACT_LIAB)。
252+
"""
253+
from stockwise.data.cache import cached_call, TTL_FINANCIALS
254+
if not fin.annual:
255+
return
256+
try:
257+
prefix = "SH" if code.startswith("6") else "SZ"
258+
symbol = f"{prefix}{code}"
259+
df = cached_call(
260+
"em:balance_sheet", symbol, TTL_FINANCIALS,
261+
lambda: ak.stock_balance_sheet_by_report_em(symbol=symbol),
262+
)
263+
except Exception:
264+
return
265+
if df is None or df.empty:
266+
return
267+
268+
# 建立 REPORT_DATE (YYYY-MM-DD) → row 索引(只取 12-31 年报)
269+
import pandas as pd
270+
df = df.copy()
271+
df["REPORT_DATE_STR"] = df["REPORT_DATE"].astype(str).str[:10]
272+
annual_rows = df[df["REPORT_DATE_STR"].str.endswith("-12-31")]
273+
by_date = {row["REPORT_DATE_STR"]: row for _, row in annual_rows.iterrows()}
274+
275+
for p in fin.annual:
276+
# period 是 "20251231",转换为 "2025-12-31"
277+
if len(p.period) >= 8:
278+
date_str = f"{p.period[:4]}-{p.period[4:6]}-{p.period[6:8]}"
279+
row = by_date.get(date_str)
280+
if row is None:
281+
continue
282+
p.accounts_receivable = _to_float(row.get("ACCOUNTS_RECE"))
283+
p.contract_liabilities = _to_float(row.get("CONTRACT_LIAB"))
284+
p.prepayments = _to_float(row.get("ADVANCE_RECEIVABLES"))
285+
286+
247287
def _a_financials(code: str, years: int = 10) -> Financials:
248288
from stockwise.data.cache import cached_call, TTL_FINANCIALS
249289
df = cached_call(
@@ -286,7 +326,10 @@ def _a_financials(code: str, years: int = 10) -> Financials:
286326
period.revenue_yoy = _yoy(by_indicator.get("revenue"), col, prev_col)
287327
period.profit_yoy = _yoy(by_indicator.get("net_profit"), col, prev_col)
288328
annual.append(period)
289-
return Financials(annual=annual)
329+
fin = Financials(annual=annual)
330+
# v0.11 #52:补充资产负债表科目(应收账款 / 合同负债)
331+
_a_balance_sheet_enrich(code, fin)
332+
return fin
290333

291334

292335
def _a_valuation(code: str) -> Valuation:

stockwise/data/industry_roe.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,51 @@ class IndustryRoeRank:
3131
_ENABLED_VIEWS = {"default", "bank", "insurance", "cyclical", "semi_growth", "growth"}
3232

3333

34+
# INDUSTRYCSRC1(akshare 细分名)→ baostock 证监会大类关键词(包含匹配)
35+
_BAOSTOCK_INDUSTRY_MAP = {
36+
# 食品 / 酒
37+
"白酒": "酒、饮料",
38+
"饮料": "酒、饮料",
39+
"食品": "食品制造",
40+
"农副食品": "农副食品加工",
41+
# 资源 / 周期
42+
"煤炭": "煤炭开采",
43+
"钢铁": "黑色金属",
44+
"有色金属": "有色金属",
45+
"石油": "石油",
46+
"化工": "化学原料",
47+
"化学制品": "化学原料",
48+
# 制造
49+
"汽车": "汽车制造",
50+
"家电": "电气机械",
51+
"家用电器": "电气机械",
52+
# 医药
53+
"医药": "医药制造",
54+
# 金融
55+
"银行": "货币金融",
56+
"货币金融": "货币金融",
57+
"保险": "保险",
58+
"证券": "资本市场",
59+
# 房地产
60+
"房地产": "房地产",
61+
# 公用
62+
"电力": "电力",
63+
"燃气": "燃气",
64+
}
65+
66+
67+
def _map_to_baostock_industry(industry: str) -> Optional[str]:
68+
"""INDUSTRYCSRC1 细分名 → baostock 证监会大类的关键词(用于 contains 匹配)。"""
69+
if not industry:
70+
return None
71+
if industry in _BAOSTOCK_INDUSTRY_MAP:
72+
return _BAOSTOCK_INDUSTRY_MAP[industry]
73+
for key, mapped in _BAOSTOCK_INDUSTRY_MAP.items():
74+
if key in industry:
75+
return mapped
76+
return None
77+
78+
3479
def fetch_industry_roe_rank(code: str, industry: Optional[str],
3580
company_roe: Optional[float]) -> IndustryRoeRank:
3681
"""对 code 计算其在行业内的 ROE 分位。
@@ -78,21 +123,27 @@ def fetch_industry_roe_rank(code: str, industry: Optional[str],
78123
def _peers_roe(industry: str) -> list[tuple[str, Optional[float]]]:
79124
"""从 baostock 拉同行业成员的近 5 年 ROE 均值。
80125
81-
简化:先拉行业表(按 INDUSTRYCSRC1 字段匹配),取前 30 个代码(baostock 行业代码无市值),
82-
然后对每只查 profit_data 取近 5 年 ROE 均值。
126+
baostock industry 字段是证监会大类格式(如 'C15酒、饮料和精制茶制造业'),
127+
与 INDUSTRYCSRC1 的细分名(如"白酒")不直接匹配。
128+
映射策略:用关键词匹配 INDUSTRYCSRC1 → baostock 大类,覆盖主要行业。
83129
"""
84130
from stockwise.industry import _ensure_baostock_login
85131
import baostock as bs
86132
_ensure_baostock_login()
87133

88-
# 同行业成员
89134
rs = bs.query_stock_industry()
90135
df = rs.get_data()
91136
if df is None or df.empty:
92137
return []
93-
# baostock industry 字段是简化名(如"采掘业"),需要关键词包含匹配
94-
members = df[df["industry"].str.contains(industry[:2], na=False)] \
95-
if len(industry) >= 2 else df[df["industry"] == industry]
138+
139+
# INDUSTRYCSRC1 → baostock 大类关键词映射(取 baostock industry 字符串需含的关键字)
140+
bs_keyword = _map_to_baostock_industry(industry)
141+
if bs_keyword:
142+
members = df[df["industry"].str.contains(bs_keyword, na=False, regex=False)]
143+
else:
144+
# fallback:用 INDUSTRYCSRC1 头 2 字
145+
members = df[df["industry"].str.contains(industry[:2], na=False, regex=False)] \
146+
if len(industry) >= 2 else df[df["industry"] == industry]
96147
members = members.head(30)
97148

98149
peers: list[tuple[str, Optional[float]]] = []

0 commit comments

Comments
 (0)