系列名:《从零搭建你的 A 股量化系统》| 专栏 S3 数据工程 | 第 9 篇 / 共 15 篇 | M3 基本面
标签:#量化投资 #数据工程 #基本面 #估值 #PE #PB #行业分类 #宏观 #M2 #BaoStock #A股 #散户
上篇(S3-008)我们把财务数据的 PIT 讲透了------报告期 ≠ 公告日,用错时点就是未来函数,BaoStock 的 6 个季频接口原生给 pubDate,是零预算做严谨 PIT 的正解。到这儿,「一家公司赚多少钱」这件事,你已经能按时点、不偷看未来了。
但选股光看「赚多少」不够,还得回答另外三个问题:它现在贵不贵?它属于哪个行业?大环境怎么样? 也就是估值、行业、宏观------这三块同样是基本面,而且同样能用 BaoStock 一个源、零成本拿到。
🔴 本篇和 S3-008 的分工 :S3-008 管财务数据的时点正确性 ------「这家公司赚了多少、什么时候你才能知道」;本篇管基本面的另外三块------「贵不贵(估值)、是谁(行业)、水暖不暖(宏观)」。两者合起来,才是一家公司的基本面。
这一篇按「先认识三块数据 → 再落库 → 最后怎么用」走:第二节讲估值的三个比率各管什么、字段其实藏在日线接口里,第三节讲行业分类能拿来做哪三件事以及它的口径陷阱,第四节讲宏观三块数据的覆盖边界,第五节把三块数据分别落库,第六节给四类用法(排雷 / 同行业比价 / 估值锚 / 宏观仓位),第七节把全文的坑收成一张速查表。
【本篇取数地图】
| 本篇数据 | 指定源 | 为什么选它 |
|---|---|---|
估值 peTTM / pbMRQ / psTTM |
BaoStock (query_history_k_data_plus 日线自带) |
免费免注册,估值字段直接挂在日线接口上 ,不用另找接口;与 adjustflag 无关 |
| 行业分类 | BaoStock query_stock_industry |
免费,一次返回全市场;注意它给的是证监会行业分类 、且是当前快照 |
| 宏观(存款/贷款基准利率、存款准备金率、货币供应量) | BaoStock 5 个宏观接口 | 免费免注册;但覆盖率要盯紧(基准利率止于 2015、准备金率止于 2018) |
单源定案 :估值、行业、宏观三类固定从 BaoStock 拿;AkShare / Tushare 只做抽样对拍,不跨源合并存储------同一类数据永远单源,这是 S3-001 §5.5 的纪律。
🕐 取数时点(BaoStock 官网口径) :日 K 线 17:30 入库、复权因子 18:00、分钟线 20:00、财务数据自 2026-01-01 起次日 1:30 入库。估值跟着日线走,所以当天收盘后不久就能拿到;宏观是月度/事件性数据,另有时点规则(见第四节)。
📖 前置知识
- 已跟完 S3-004 ~ S3-008(本地 Parquet 库、DuckDB 默认查询引擎、
data/fundamental/财务落库、PIT 时点纪律); - 清楚架构铁律(v1.4):日线存不复权 raw、
adj_factor独立因子表、折算在查询层; - 一点估值常识:PE、PB、PS 都是「市值 ÷ 某个财务分母」,分位低通常意味着相对便宜(三者的分工见第二节)。
一、先说结论
这一篇只有一句话内核:估值、行业、宏观三块基本面,BaoStock 一个源就能全包------但每一块都有它自己的口径陷阱:估值要防负值、行业要防"拿今天的分组跑昨天"、宏观要防"数据早就停了你还以为没变化"。 先看红线表:
| 🔴 红线 | 原因(一句话) | 正确做法 | 详见 |
|---|---|---|---|
| 以为估值要单独找接口 | 估值不在单独接口,就挂在日线 k-data 上 | query_history_k_data_plus 的 fields 里带 peTTM,pbMRQ,psTTM |
第二节 |
| 用复权价自己算 PE | 复权价被改过,算出来是错的 | 直接用接口给的 peTTM;要自己算就用 raw 价 ÷ TTM 每股收益 |
第二节 |
把负 peTTM 当"便宜" |
亏损股 PE 为负,越小看着越"低" | 做估值筛选前先过滤 peTTM <= 0 |
第二节 |
| 拿"当前行业快照"跑历史 | 快照是今天的,回测 2020 年 = 提前知道它今天归哪个行业 | 静态近似要声明口径;要历史归属得另找成分源 | 第三节 |
| 用宏观数据直接下结论 | 基准利率止于 2015、准备金率止于 2018,之后它没记 | 用之前先看数据最后一条的日期 | 第四节 |
| 直接取 M2 最后一行 | 接口有当月占位空行,取到的是 NaN | 过滤空值,取最后一条有效记录 | 第四节 |
这六条分别在第二~四节逐块展开;跑代码出问题时,回第七节的速查表对号入座。
二、估值:PE / PB / PS 各回答什么问题
先说清楚这三个比率各自盯着什么------它们不是三个可以随便挑一个用的数字:
| 指标 | 算式 | 回答的问题 | 主要适用 |
|---|---|---|---|
| PE 市盈率 | 总市值 ÷ 净利润 | 为 1 元利润付了多少钱 | 盈利稳定、可预期的公司(消费、公用事业、成熟制造) |
| PB 市净率 | 总市值 ÷ 净资产 | 为 1 元净资产付了多少钱 | 资产本身就是生意的行业(银行、保险、地产、钢铁) |
| PS 市销率 | 总市值 ÷ 营业收入 | 为 1 元收入付了多少钱 | 净利润为负但有收入的成长股,此时 PE 已失效 |
有两个「反过来」得先知道,否则方向会用错:
① 周期股的 PE 是反的。 钢铁、煤炭、航运、养殖这类行业的利润随景气大起大落:景气顶点利润最高、PE 最低(看着最便宜);谷底利润趋零甚至亏损、PE 最高或为负(看着最贵)。用 PE 给它们排序,排出来的不是便宜度,是利润坍缩的程度。 实测钢铁行业(C31 前 12 只,2026-09-30):5 只 PE 为负,另有 3 只高达 126 ~ 188 倍------同一个行业、同一天,PE 从负值一路跨到近两百倍;再按各自的 2019 年以来历史分位排,最低的一只也在 56.8% 分位。这类行业更适合看 PB,净资产不像利润那样会在谷底趋零。
② 低 PB 不等于便宜。 三个比率的分子都是市值,于是有恒等式 PB ÷ PE = 净利润 ÷ 净资产 = ROE ,即 PB = PE × ROE 。它的意思是:净资产只有能赚钱才值钱,低 PB 配上逐年下滑的 ROE 是价值陷阱,不是折扣 。ROE 从 S3-008 落的那张利润表里取(roeAvg)。留意两边口径不同:pbMRQ ÷ peTTM 反推的是「TTM 净利润 ÷ 期末净资产」,财报 roeAvg 是「累计净利润 ÷ 平均净资产」,数值会差一点,用了就别混。
这三个字段要从哪里拿?BaoStock 的日线接口本来就带 。只要在 fields 里点名,就跟着日线一起返回:
python
rs = bs.query_history_k_data_plus(
"sh.600519",
"date,code,close,peTTM,pbMRQ,psTTM,pcfNcfTTM,turn,tradestatus,isST",
start_date="2019-01-01", end_date="2026-09-30",
frequency="d", adjustflag="3") # adjustflag=3 不复权
实测茅台 600519(脚本 --real,2026-10-01):
text
[估值] sh.600519 样本 1880 天(2019-01-02 ~ 2026-09-30)
最新 2026-09-30: close=1258.62 peTTM=19.3209 pbMRQ=6.2621 psTTM=9.0821
peTTM: 正值样本 1880 天, 当前分位 = 2.7%
pbMRQ: 正值样本 1880 天, 当前分位 = 4.0%
psTTM: 正值样本 1880 天, 当前分位 = 2.0%
翻译成人话:到 2026-09-30,茅台的 PE(TTM)是 19.32 倍 、PB 是 6.26 倍 、PS 是 9.08 倍 ;把这三条序列摊到 2019 年以来的全部 1880 个交易日上看,当前 PE 处在近 7 年只有 2.7% 的时间比它更低的位置,PB 在 4.0% 分位,PS 在 2.0% 分位------三个指标同时落在历史极低区。
📏 这三个字段的定义:
peTTM= 市值 / 最近 12 个月净利润(滚动市盈率);pbMRQ= 市值 / 最新一期净资产(市净率);psTTM= 市值 / 最近 12 个月营业收入(市销率)。都是"比率",和复权无关------下面这个实测专门验证这点。
复权改的是价格,不是估值比率
同一只股票、同一天,只改 adjustflag:
| adjustflag | close(2024-04-01) | peTTM | pbMRQ |
|---|---|---|---|
3(不复权) |
1721.33 | 30.372869 | 9.927665 |
2(前复权) |
1564.23 | 30.372869 | 9.927665 |
价格从 1721.33 掉到 1564.23(复权调整了),但 peTTM / pbMRQ 一模一样。原因很简单:估值比率是「市值 ÷ 财报口径的利润/净资产」,分子分母同源,不会因为你把 K 线画成复权或复权不画而变。
🔴 反过来推一条铁律 :既然接口给的
peTTM就是标准答案 ,你就别拿复权价 ÷ 每股收益自己造 PE ------复权价是被调整过的(上表里 1721 → 1564),拿它算出来的 PE 是错的。要自己算,只用 raw 价。
用「分位」而不是「绝对值」
PE = 30 到底贵不贵?没有绝对值答案,只有相对答案。同一只股票自己的历史分位才是散户最好用的尺子:
python
pos = df["peTTM"][(df["peTTM"] > 0)] # 只在正值上算分位
pct = (pos <= df["peTTM"].iloc[-1]).sum() / len(pos) # 当前值的历史分位
茅台当前 PE 分位 2.7%(PE 19.32),意思是过去 7 年里 97% 的时间它比现在贵。这个信息比「PE 19 是高是低」有用得多。
负 PE 是地雷,不是便宜
估值接口会返回负的 peTTM ------公司亏损时,市值 ÷ 负利润 = 负 PE。实测 sh.600606(绿地控股)在 2024-04-01 的 peTTM = -7.847860。如果你用「PE 越低越便宜」去排序,负 PE 会排在所有正常股前面,等于专挑亏损股买。
python
# ❌ 危险:负 PE 会排最前
cheap = df.sort_values("peTTM").head(50)
# ✅ 正确:先过滤非正值,再排序
cheap = df[df["peTTM"] > 0].sort_values("peTTM").head(50)
📏 所以脚本里的分位函数只在正值样本 上计算,并单独打印「
peTTM为负的天数」作为体检项。
三条口径纪律
用估值数据之前,这三条先把住。
- 别混用口径 :PE 用「市值 ÷ 净利润」,但静态 PE(上年年报)、TTM(滚动 12 个月)、动态 PE(预测)数值差很多------选一个,并且标明选的是哪个;
- 别拿复权价算 :复权价被调整过(上面实测 1721.33 → 1564.23),要自己算只能用 raw 价;其实接口已经给了
peTTM,多数情况直接用它; - 别只看绝对值:和自家历史比(分位)、和同行业比(第三节),才是估值的正经用法。
三、行业:BaoStock 给的是证监会分类,而且是"当前快照"
行业分类不是拿来填表的,它解决三件事:
- 让估值可比------不同行业的估值中枢差着量级,跨行业比 PE 得出的结论是假的(第六节有实测数字);
- 约束组合别押在一个行业------按行业分组统计持仓,才看得出自己是不是「伪分散」:买了 10 只,8 只同属一个门类;
- 看整个行业是不是一起变贵------同行业个股估值同步抬升,反映的往往是行业景气,而不是个股便宜。
query_stock_industry() 一次返回全市场行业归属。实测字段与样本:
text
fields = ['updateDate', 'code', 'code_name', 'industry', 'industryClassification']
['2026-09-28', 'sh.600519', '贵州茅台', 'C15酒、饮料和精制茶制造业', '证监会行业分类']
['2026-09-28', 'sh.600000', '浦发银行', 'J66货币金融服务', '证监会行业分类']
['2026-09-28', 'sh.600001', '邯郸钢铁', '', '证监会行业分类']
两个容易被搞混的地方:
① 它是「证监会行业分类」,不是申万。 industryClassification 字段实测恒为「证监会行业分类 」。字符串形如 C15酒、饮料和精制茶制造业 = 门类字母(C 制造业)+ 大类代码(15)+ 名称。
⚠️ 别把它当申万用 。A 股行业分类有好几套:证监会(门类 A~S,就是这里的)、申万(一级 31 个)、中证等。做「行业中性化」「申万一级行业轮动」这类策略时,先确认你手上的字段是哪一套------拿证监会门类去套申万的逻辑,对不上。本系列固定用 BaoStock 这一套(证监会),要申万得另找源、并声明口径。
② 它是「当前快照」,不是历史。 实测 updateDate = 2026-09-28------全表只有这一个快照日 ,返回的是"这些股票今天属于哪个行业",不含"历史上什么时候变更过"。
这带来一个隐蔽的坑:
python
# ❌ 前视偏差:用 2026 年的行业归属,去回测 2020 年的行业轮动
industry = fetch_industry(bs) # 快照日 2026-09-28
backtest_2020(industry) # 等于 2020 年就知道谁今天归哪个行业
要做历史行业策略,你至少得: ① 明确声明这是「静态近似」(用当前归属代表历史,承认偏差);② 或者另找带历史成分的源。BaoStock 这套只有快照------把它当成"近似当下分组"用没问题,当成"历史真相"用就是自我欺骗。
📏 全市场实测 5556 行 ,行业为空字符串的多为退市股 (如
sh.600001 邯郸钢铁)------所以按行业筛股票池时,空行业要单独处理,别让它默默影响分组统计。
四、宏观:利率 / 准备金率 / M2,以及它们的三个坑
宏观影响股价的逻辑链是:货币宽松 → 市场流动性变多 → 无风险利率下行 → 贴现率下降 → 估值分母变小 → 市场愿意给的倍数抬升 (就是 DCF 分母里那个 r)。它改的是「市场愿意为利润付多少倍」,不是「公司赚多少钱」------后者归 S3-008 的财务表管。所以宏观数据天然作用在估值倍数 上,而不是收益上。
这条链子还很长:从央行操作到实体、再到企业盈利、再到股价,中间隔着几周到几个季度的时滞,加上市场提前抢跑预期。结果是宏观数据只能用来定「仓位基调」,做不了买卖信号,第六节把它排在最后一位就是这个原因。
BaoStock 提供 5 个宏观接口。实测覆盖率如下(这部分坑最密):
| 接口 | 数据行数 | 实测覆盖区间 | 状态 |
|---|---|---|---|
query_deposit_rate_data(存款基准利率) |
43 | 1990-04-15 ~ 2015-10-24 | 🔴 2015-10 后冻结 |
query_loan_rate_data(贷款基准利率) |
43 | 1990-04-15 ~ 2015-10-24 | 🔴 2015-10 后冻结 |
query_required_reserve_ratio_data(存款准备金率) |
47 | 1999-11-18 ~ 2018-06-24 | 🔴 2018 年后未更新 |
query_money_supply_data_month(货币供应量·月) |
随年份 | 2015-01 ~ 2026-08(有效) | ✅ 正常更新 |
query_money_supply_data_year(货币供应量·年) |
15 | 2010 ~ 2024 | ✅ 正常 |
坑 1:基准利率自 2015 年就冻结了,别用它判「央行动没动」
实测存款 / 贷款基准 利率的最后一条都是 2015-10-24 (1 年期定存 1.5%)。这不是数据缺失,是事实 ------2015 年 10 月央行放开存款利率浮动上限后,就基本不再调整「基准利率」,货币政策转向 LPR 和 公开市场操作。
如果你写「基准利率没变 → 央行没动作」,会得出2015 年之后央行啥也没干 的荒谬结论。真相是:能反映近年货币政策松紧的,是「存款准备金率」和「LPR」,不是已经名存实亡的基准利率。
坑 2:准备金率数据止于 2018-06-24,2018 年后多次降准它没记
query_required_reserve_ratio_data 全量 47 条 ,最后一条是 2018-06-24 (公告日)/ 2018-07-05 (生效日),大型金融机构 16.0% → 15.5%。这之后央行多轮降准,BaoStock 这个接口没有收录。
🔴 结论 :用宏观数据之前,先看它最后一条的日期 。看到 2018 就以为「此后准备金率一直没变」,是拿数据源的覆盖边界当成了经济事实。这条对准备金率、对基准利率同时成立。
坑 3:货币供应量 M2 会返回「当月占位空行」
query_money_supply_data_month 是这几个接口里最健康 的,正常更新到实测的 2026-08(M2 3,568,083.60 亿元、同比 7.50%)。但它有个隐蔽行为:查询区间覆盖到"尚未发布"的月份时,接口会返回一行"占位空行" ------statYear/statMonth 有值,m2Month/m2YOY 全是空字符串。
python
# ❌ 直接取最后一行:如果最后一行是当月占位空行,你拿到的是 NaN
last = m2m.iloc[-1]
# ✅ 过滤空值,取最后一条有效记录
valid = m2m[m2m["m2Month"].astype(str).str.strip() != ""]
last = valid.iloc[-1]
实测:查询到 2026-10(当前月)时,返回 141 行里最后 1 行是空占位 ,真正有效的最新月是 2026-08 。这个坑和 S3-008 里"季频财务末尾空行"是同一类------数据源用"有行但空值"表示"还没发布",不是"没有数据"。
宏观的「可用日」:三个接口给的东西不一样
| 数据类型 | 时点字段 | 可用日规则 |
|---|---|---|
| 准备金率 | pubDate(公告日)+ effectiveDate(生效日) |
用 pubDate------公告即公开(生效日反而晚) |
| 存款 / 贷款基准利率 | 只有 pubDate |
用 pubDate |
| 货币供应量 M2 | 只有 statYear / statMonth,没有公告日 |
只能保守推定 为统计期次月首日 |
#mermaid-svg-FL0IsJ1gOTMUsBA6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .error-icon{fill:#552222;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .marker.cross{stroke:#333333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 p{margin:0;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster-label text{fill:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster-label span{color:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster-label span p{background-color:transparent;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .label text,#mermaid-svg-FL0IsJ1gOTMUsBA6 span{fill:#333;color:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .node rect,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node circle,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node ellipse,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node polygon,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .rough-node .label text,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node .label text,#mermaid-svg-FL0IsJ1gOTMUsBA6 .image-shape .label,#mermaid-svg-FL0IsJ1gOTMUsBA6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .rough-node .label,#mermaid-svg-FL0IsJ1gOTMUsBA6 .node .label,#mermaid-svg-FL0IsJ1gOTMUsBA6 .image-shape .label,#mermaid-svg-FL0IsJ1gOTMUsBA6 .icon-shape .label{text-align:center;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .node.clickable{cursor:pointer;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .arrowheadPath{fill:#333333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FL0IsJ1gOTMUsBA6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FL0IsJ1gOTMUsBA6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster text{fill:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .cluster span{color:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FL0IsJ1gOTMUsBA6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .icon-shape,#mermaid-svg-FL0IsJ1gOTMUsBA6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .icon-shape p,#mermaid-svg-FL0IsJ1gOTMUsBA6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .icon-shape .label rect,#mermaid-svg-FL0IsJ1gOTMUsBA6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FL0IsJ1gOTMUsBA6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FL0IsJ1gOTMUsBA6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FL0IsJ1gOTMUsBA6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 准备金率
pubDate + effectiveDate
可用日 = pubDate
存款/贷款利率
只有 pubDate
可用日 = pubDate
货币供应量 M2
只有 statYear/statMonth
可用日 = 统计期次月首日
(无公告日,保守推定)
🔴 和前文的 PIT 纪律一脉相承 :有的接口给公告日,按公告日对齐;没有公告日的(M2),只能往晚里推(统计期次月首日),宁可晚一天,不可抢跑一天。宏观的时点精度天然比财务差,回测里用它做信号时,这条要写进口径声明。
五、落库:三类数据长得不一样
估值、行业、宏观的数据形态差异很大,落库要分开处理(脚本 --store):
text
data/fundamental/valuation/{symbol}.parquet # 估值序列(按标的,逐日)
data/fundamental/industry/industry_snapshot.parquet # 行业快照(全市场一张表)
data/macro/{kind}.parquet # 宏观(m2month / rrr / deposit / loan)
实测落库 + DuckDB 读回:
text
[完成] valuation 1880 行 -> data\fundamental\valuation\600519.SH.parquet (读回 1880 行)
[完成] industry 5556 行 -> data\fundamental\industry\industry_snapshot.parquet
[完成] macro/m2month 141 行 -> data\macro\m2month.parquet(含 1 行占位空行,未发布月;查询时按 m2Month 非空过滤)
[完成] macro/rrr 47 行 -> data\macro\rrr.parquet
[完成] macro/deposit 43 行 -> data\macro\deposit.parquet
[完成] macro/loan 43 行 -> data\macro\loan.parquet
三类的落库要点:
- 估值 :和日线同形(逐日),按
{symbol}.parquet存,跟随日线增量(S3-006~008 那套); - 行业 :快照型 ,全市场一张表,每次覆盖更新;表里带
updateDate,用它标记"这是哪天的分组"; - 宏观 :事件型 (利率/准备金率)和周期型 (M2 按月),分别存,附
source/fetch_date元信息------换源迁移和质量校验全靠它们(S3-001 §5.5 落库纪律)。
📏 三类表都带
source="baostock"+fetch_date两列。PIT 铁律在这里同样适用:凡是要按公告日对齐的表(利率、准备金率),无pubDate直接拒绝入库 (脚本里的assert_has_pubdate())。
还有一条:取数区间的上限不要写死年份 。写死之后报告和落库会悄悄分成两个口径------测试时遇到过 m2month 上限写成 2025-12,报告说"最新有效月 2026-08",落库却只到 2025-12,差 8 个月,而且不报任何错。修法是把区间抽成一个共用函数(脚本里的 macro_window()),报告与落库都从"今天"取上限,顺带把"上限必须是今天"焊进自检。
三块数据从头到尾长这样------取值、按各自的口径归一、落进各自的表,最后汇总到同一个用法上:
#mermaid-svg-mh03QchkYGkQAksp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mh03QchkYGkQAksp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mh03QchkYGkQAksp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mh03QchkYGkQAksp .error-icon{fill:#552222;}#mermaid-svg-mh03QchkYGkQAksp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mh03QchkYGkQAksp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mh03QchkYGkQAksp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mh03QchkYGkQAksp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mh03QchkYGkQAksp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mh03QchkYGkQAksp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mh03QchkYGkQAksp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mh03QchkYGkQAksp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mh03QchkYGkQAksp .marker.cross{stroke:#333333;}#mermaid-svg-mh03QchkYGkQAksp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mh03QchkYGkQAksp p{margin:0;}#mermaid-svg-mh03QchkYGkQAksp .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mh03QchkYGkQAksp .cluster-label text{fill:#333;}#mermaid-svg-mh03QchkYGkQAksp .cluster-label span{color:#333;}#mermaid-svg-mh03QchkYGkQAksp .cluster-label span p{background-color:transparent;}#mermaid-svg-mh03QchkYGkQAksp .label text,#mermaid-svg-mh03QchkYGkQAksp span{fill:#333;color:#333;}#mermaid-svg-mh03QchkYGkQAksp .node rect,#mermaid-svg-mh03QchkYGkQAksp .node circle,#mermaid-svg-mh03QchkYGkQAksp .node ellipse,#mermaid-svg-mh03QchkYGkQAksp .node polygon,#mermaid-svg-mh03QchkYGkQAksp .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mh03QchkYGkQAksp .rough-node .label text,#mermaid-svg-mh03QchkYGkQAksp .node .label text,#mermaid-svg-mh03QchkYGkQAksp .image-shape .label,#mermaid-svg-mh03QchkYGkQAksp .icon-shape .label{text-anchor:middle;}#mermaid-svg-mh03QchkYGkQAksp .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mh03QchkYGkQAksp .rough-node .label,#mermaid-svg-mh03QchkYGkQAksp .node .label,#mermaid-svg-mh03QchkYGkQAksp .image-shape .label,#mermaid-svg-mh03QchkYGkQAksp .icon-shape .label{text-align:center;}#mermaid-svg-mh03QchkYGkQAksp .node.clickable{cursor:pointer;}#mermaid-svg-mh03QchkYGkQAksp .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mh03QchkYGkQAksp .arrowheadPath{fill:#333333;}#mermaid-svg-mh03QchkYGkQAksp .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mh03QchkYGkQAksp .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mh03QchkYGkQAksp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mh03QchkYGkQAksp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mh03QchkYGkQAksp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mh03QchkYGkQAksp .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mh03QchkYGkQAksp .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mh03QchkYGkQAksp .cluster text{fill:#333;}#mermaid-svg-mh03QchkYGkQAksp .cluster span{color:#333;}#mermaid-svg-mh03QchkYGkQAksp div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mh03QchkYGkQAksp .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mh03QchkYGkQAksp rect.text{fill:none;stroke-width:0;}#mermaid-svg-mh03QchkYGkQAksp .icon-shape,#mermaid-svg-mh03QchkYGkQAksp .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mh03QchkYGkQAksp .icon-shape p,#mermaid-svg-mh03QchkYGkQAksp .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mh03QchkYGkQAksp .icon-shape .label rect,#mermaid-svg-mh03QchkYGkQAksp .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mh03QchkYGkQAksp .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mh03QchkYGkQAksp .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mh03QchkYGkQAksp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 落库
按各自口径归一
取数(BaoStock,单源)
估值
query_history_k_data_plus
fields 带 peTTM/pbMRQ/psTTM
行业
query_stock_industry
证监会分类 + 当前快照
宏观
deposit / loan / rrr / m2 月·年
只在正值样本上算分位
标出 updateDate 快照日
空行业单独处理
过滤占位空行
定可用日(pubDate / 次月首日)
data/fundamental/valuation/{symbol}.parquet
data/fundamental/industry/industry_snapshot.parquet
data/macro/{kind}.parquet
散户用法:排雷 → 同行业比价 → 估值锚 → 择时(别指望)
六、四个用法:排雷 > 同行业比价 > 估值锚 > 择时(别指望)
数据取到、时点对齐、库也落了,剩下最实际的问题:这三块能拿来做点什么。 按对散户的实际价值排四档。
① 排雷:先把不该进池子的剔出去
见效最快的是排除法,三条规则挡住大部分脏样本:
| 规则 | 拦什么 | 实测样本 |
|---|---|---|
peTTM <= 0 |
亏损股 | 绿地控股 sh.600606 peTTM = -7.847860;白酒行业 12 只抽样里 4 只 PE 为负 |
industry 为空串 |
退市股 | sh.600001 邯郸钢铁(行业字段是空字符串) |
isST = 1 |
风险警示股 | 日线接口自带这个字段,不用另找源 |
三条都不做,后面所有排序都是在垃圾堆里挑------peTTM 为负的会稳稳排到「最便宜」那一端。
② 同行业比价:只在同行业内部比
跨行业比 PE 是无效动作。同一天(2026-09-30)、同一个市场,三个行业各抽 12 只:
| 行业 | peTTM 中位数 | pbMRQ 中位数 | PE 为负 |
|---|---|---|---|
| 白酒饮料 C15 | 28.16 | 2.18 | 4 只 |
| 银行 J66 | 6.15 | 0.66 | 0 只 |
| 钢铁 C31 | 13.10 | 1.54 | 5 只 |
银行 PE 6.15、PB 0.66 是「便宜」吗?白酒 28 是「贵」吗?都不是。银行高杠杆、资产重,白酒轻资产、高毛利,估值中枢由生意模式决定,不由划算不划算决定。跨行业比出来的是行业差异,不是便宜度。
(这张表一行命令就有:--use --industries C15,J66,C31 --n-per 12。它是逐只取数的------本次实测 BaoStock 单只约 20 秒,一个行业 12 只大概四分钟,服务端快的时候短很多。)
能用的尺子是同一行业内部的历史分位。同一批白酒股(C15 前 12 只)排一遍:
text
[用法演示] 行业 C15:成分 47 只,取前 12 只
(逐只取数日志略)
估值窗口 2019-01-01 ~ 2026-10-01(每只 1880 个交易日)
peTTM 中位数 = 28.16(正值 8 只 / 负值 4 只 / 缺失 0 只)
pbMRQ 中位数 = 2.18
估值分位最低 3 只:
sh.600132 close=38.03 peTTM=15.84 分位 0.3%
sh.600519 close=1258.62 peTTM=19.32 分位 2.7%
sh.600573 close=10.42 peTTM=29.21 分位 2.9%
估值分位最高 3 只:
sh.600059 close=12.27 peTTM=50.03 分位 82.3%
sh.600189 close=8.95 peTTM=210.97 分位 65.4%
sh.600197 close=9.32 peTTM=32.50 分位 63.1%
这份名单要读对:分位低是说「相对它自己过去 7 年便宜」,不等于「值得买」。sh.600132 分位 0.3% 只说明它现在比 2019 年以来的任何一天都便宜,至于为什么这么便宜,得回到财报去看。
还有两条边界要一起带着:样本太少别排 ------白酒 12 只里 4 只亏损,能参与排序的只剩 8 只,一个行业凑不到十几只,排出来的名次没有意义;分位是相对量------同一只股票,回看窗口从 2019 改成 2021,分位就变了,声明口径时要把窗口一起写上。
③ 估值锚:只当候选池,配质量才有意义
同一个行业内部,绝对值最低的和分位最低的经常不是同一批。银行 12 只里最扎眼的一对:
| 代码 | close | peTTM | 自己的历史分位 |
|---|---|---|---|
| sh.600919 | 12.38 | 6.29(绝对值最低档) | 90.9%(自己历史最贵档) |
| sh.600318 | 6.80 | 81.86(绝对值最高档) | 18.0%(自己历史偏便宜档) |
PE 最低的那只在它自己历史上处于 90.9% 分位,PE 看着吓人的那只反而在 18% 分位。绝对值不可比、分位才可比,这就是实测证据。 (顺带一句,peTTM 涨到三位数通常是利润基数太小把分母压坍了,这种 PE 本身就该存疑。)
分位排出来也只是一张候选池------便宜可能是陷阱。把 S3-008 那张利润表的 roeAvg 拉过来配上:
| ROE 稳定或回升 | ROE 逐年下滑 | |
|---|---|---|
| 估值低分位 | 值得往下查 | 价值陷阱,低 PB 尤其要小心 |
| 估值高分位 | 贵,但有基本面托着 | 又贵又在变差 |
④ 宏观:只定仓位基调
M2 同比(实测 2026-08:3,568,083.60 亿元、同比 7.50%)、准备金率方向,频率是月度和事件性的,只够判断「水在放还是在收」------用来定仓位上限,定不了买卖点。
反例:把「M2 同比回升」当买入信号。月度数据要等到次月才发布(没有公告日,只能保守推到次月首日),传导到股价又隔几个季度,等信号确认,行情常常已经走完。
📌 一句实话:估值、行业、宏观这三块,散户最靠得住的是排雷 和同行业比价 ,不是靠宏观择时抓拐点。机构有宏观研究团队,信息速度和解读深度你都比不过;但这三类数据公开、免费、只要口径对齐就不吃亏------这才是把它们老老实实取好、存好、用对时点的意义。
三件别做的事:跨行业比 PE;用 PE 绝对值排序全市场;拿宏观做择时。
七、踩坑速查表
下面这张表是回查用的索引:跑代码报错、或拿到数据觉得不对劲时对号入座,最后一列指回详细论述的那一节。
| 症状 | 后果 | 正确做法 | 详见 |
|---|---|---|---|
| 满世界找「估值接口」 | 白折腾 | 估值就在日线接口的 fields 里:peTTM,pbMRQ,psTTM |
第二节 |
| 拿复权价 ÷ 每股收益自算 PE | 算出来偏(同日 raw 1721.33 vs 前复权 1564.23) | 直接用接口给的 peTTM;自算只用 raw 价 |
第二节 |
| 用「PE 越低越便宜」排序 | 负 PE 冲到最前,等于专挑亏损股 | 先 df[df["peTTM"] > 0] 再排序 |
第二节 |
| 用 PE 给周期行业排便宜 | 利润谷底趋零,PE 会涨到上百倍或变负,排出来的是利润坍缩程度(实测钢铁 12 只里 5 只为负、3 只 126 ~ 188 倍) | 周期行业看 PB,别用 PE 绝对值 | 第二节 |
拿 pbMRQ ÷ peTTM 反推的 ROE 跟财报 roeAvg 直接比 |
两边口径不同(TTM vs 累计、期末 vs 平均净资产),数值对不上 | 要用 ROE 就用 S3-008 的 roeAvg,别混着用 |
第二节 |
| 拿证监会行业套申万的逻辑 | 行业中性化、行业轮动全对不上 | 先确认字段是哪一套;要申万另找源并声明口径 | 第三节 |
| 用行业快照跑历史回测 | 前视偏差,提前知道它今天归哪个行业 | 声明「静态近似」,或另找带历史成分的源 | 第三节 |
| 按行业分组做统计 | 退市股行业为空字符串,静默污染分组 | 空行业单独处理,不进统计口径 | 第三节 |
| 跨行业比 PE 挑「便宜」 | 估值中枢差数倍(银行中位 6.15 vs 白酒 28.16),比出来的是行业差异 | 只在同行业内部比历史分位 | 第六节 |
| 用基准利率判断央行松紧 | 数据止于 2015-10-24,会得出「央行十年没动作」 | 近年看 LPR 与存款准备金率 | 第四节 |
| 看到准备金率最后一条是 2018 | 把数据覆盖边界当成经济事实 | 用宏观数据前先看最后一条日期 | 第四节 |
m2m.iloc[-1] 取最新月 |
拿到当月占位空行,值是 NaN | 过滤 m2Month 空值后取最后一条有效行 |
第四节 |
| 宏观三类统一按公告日对齐 | M2 没有公告日,强行对齐会算错时点 | 利率 / 准备金率用 pubDate;M2 保守推次月首日 |
第四节 |
| 行业内样本只有个位数就排分位 | 名次没有统计意义(白酒 12 只里 4 只亏损,可排序的只剩 8 只) | 凑够十几只再比,或换成分更多的行业 | 第六节 |
| 用不同回看窗口的分位互相比较 | 分位是相对量,窗口不同结论不同 | 分位必须连同回看窗口一起声明 | 第六节 |
八、本篇小结 & 下篇预告
小结:这一篇把基本面的另外三块(估值 / 行业 / 宏观)用 BaoStock 一个源取齐了:
- 估值 :
peTTM/pbMRQ/psTTM就挂在日线接口上,且与复权无关------茅台当前三个指标同时落在近 7 年极低分位; - 行业 :
query_stock_industry给的是证监会行业分类 (不是申万)、且是当前快照,拿它跑历史就是前视偏差; - 宏观:三个接口各有各的覆盖边界,M2 还有当月占位空行;宏观三类的可用日规则也不一样;
- 用法 :排雷(
peTTM <= 0/ 空行业 /isST)> 同行业比价 > 估值锚(配 S3-008 的roeAvg)> 宏观只定仓位;每个坑怎么处置见第七节速查表。
你现在攒下的基本面底座:S3-008 的财务 PIT(赚多少、能不能看)+ 本篇的估值 / 行业 / 宏观(贵不贵、是谁、水暖不暖) 。但还有一块------股票池本身:哪些标的该进、哪些该退(ST / 退市 / 停牌 / 次新),这块不处理干净,前面的数据再准也白搭。
下一篇预告 :专栏 S3 的第十篇《股票池管理:ST、退市、停牌、次新的处理规则》。你会看到回测里最经典的杀手------幸存者偏差:用「当前还在上市的股票列表」跑历史,等于提前知道谁活到了今天。退市股、ST 股必须留在历史池里直到真正退场;停牌、次新也各有各的处理规则。带上本篇「口径要对」的觉悟,我们下篇见。
附:可运行脚本(本篇全部代码)
本篇交付文件(脚本同步一份到 E:\ashare-quant\src/,已实跑):
| 文件 | 作用 |
|---|---|
src/s3_009_fundamental.py |
基本面引擎:BaoStock 估值(日线带 peTTM/pbMRQ/psTTM)+ 行业快照(证监会分类)+ 宏观(利率/准备金率/M2)+ 同行业估值比价 + 落库读回 + 离线自检 |
环境准备:
bash
conda activate ashare
pip install baostock -i https://mirrors.aliyun.com/pypi/simple/ # BaoStock 只能 pip
pip install pandas pyarrow duckdb # 落库 / 查询
运行顺序:
bash
python src/s3_009_fundamental.py --self-test # 离线 22/22 ✅
python src/s3_009_fundamental.py --real # 估值 + 行业 + 宏观报告(联网)
python src/s3_009_fundamental.py --use # 同行业估值比价(联网)
python src/s3_009_fundamental.py --store # 落库 + DuckDB 读回
四种模式各干什么:
--self-test:22 条确定性断言,覆盖代码风格互转、空值、行业串解析、估值分位、降准方向、宏观日期与可用日推定、取数区间上限 (防写死年份)、占位空行拆分 、PIT 铁律、负 PE 识别------全部离线,不联网;--real:拉真实估值 / 行业 / 宏观,打印分位、行业归属、宏观覆盖实测;--use:同行业估值比价------按证监会行业前缀取一个行业的成分股,逐只算估值分位并排序(第六节的数字就是它跑出来的,默认C15,J66,用--industries/--n-per调);--store:落data/fundamental/与data/macro/,再用 DuckDB 读回验证「入对了、能被用」。
⚠️
--self-test全部隔离在内存,不联网、不写盘 ;--real/--store需联网(BaoStock 免 token)。文中估值分位、行业快照日、宏观覆盖区间均来自 2026-10-01 本机实测(condaashare/ baostock 0.9.3),并经 2026-10-02 复跑核验一致;--use的估值窗口终点取运行当天,你跑出来的日期会跟文中不同。数据会随数据源更新与市场变化,请按你自己的环境重跑校准。
完整脚本 src/s3_009_fundamental.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
s3_009_fundamental.py ------ S3-009 基本面:估值 / 行业 / 宏观(BaoStock)
对应博客:S3-009《基本面:估值 / 行业 / 宏观(BaoStock)》
═══════════════════════════════════════════════════════════════
定位(一句话)
═══════════════════════════════════════════════════════════════
S3-008 把财务数据的「时点正确性」(PIT)讲完了;本篇补基本面剩下三块,
全部由 BaoStock 一个源免费提供:
1. 估值:peTTM / pbMRQ / psTTM ------ **不在单独接口,就藏在日线 k-data 里**;
2. 行业:query_stock_industry ------ 给的是**证监会行业分类**(不是申万),且是**当前快照**;
3. 宏观:存款/贷款基准利率、存款准备金率、货币供应量(月/年)。
═══════════════════════════════════════════════════════════════
实测事实(2026-10-01,conda ashare,baostock 0.9.3)
═══════════════════════════════════════════════════════════════
- 估值字段挂在 query_history_k_data_plus(frequency="d") 上,配
fields="date,code,close,peTTM,pbMRQ,psTTM,pcfNcfTTM,turn,tradestatus,isST" 取回;
peTTM/pbMRQ/psTTM **与 adjustflag 无关**(复权只改 close,不改估值比率)。
- ⚠️ peTTM 可以为负(亏损股),如 sh.600606 = -7.847860 → 做估值筛选必须先过滤。
- 行业:query_stock_industry() 返回全市场快照,字段
updateDate/code/code_name/industry/industryClassification,
industryClassification 实测恒为「证监会行业分类」;industry 形如「C15酒、饮料和精制茶制造业」;
退市股 industry 为空字符串。
- 宏观数据覆盖(实测全量):
query_deposit_rate_data 43 条,1990-04-15 ~ **2015-10-24**(之后冻结)
query_loan_rate_data 43 条,1990-04-15 ~ **2015-10-24**(之后冻结)
query_required_reserve_ratio_data 47 条,1999-11-18 ~ **2018-06-24**(之后未更)
query_money_supply_data_month 正常更新(实测有效至 2026-08,且返回当月占位空行)
query_money_supply_data_year 正常更新(2010 ~ 2024 可取)
- 宏观时点:准备金率有 pubDate(公告日) + effectiveDate(生效日);存款/贷款只有 pubDate;
货币供应量只有 statYear/statMonth(无公告日)→ 可用日只能保守推定为次月。
- ⚠️ 取数区间由 macro_window() 统一给:上限一律取「今天」,**不要写死年份**------
写死会让落库在跨年后静默截断(实测踩过:m2month 上限写 2025-12,
落库 132 行落后接口最新月 8 个月,而同一份 --real 报告里却显示最新到 2026-08)。
═══════════════════════════════════════════════════════════════
用法(conda activate ashare,无需 token)
═══════════════════════════════════════════════════════════════
python src/s3_009_fundamental.py --self-test # 离线自检,不联网
python src/s3_009_fundamental.py --real # 估值+行业+宏观报告(联网)
python src/s3_009_fundamental.py --store # 落库(联网)
python src/s3_009_fundamental.py --use # 用法演示:同行业估值比价(联网)
python src/s3_009_fundamental.py --real --code sh.600519 --start 2019-01-01
python src/s3_009_fundamental.py --use --industries C15,J66 --n-per 12
"""
from __future__ import annotations
import argparse
import datetime as dt
import sys
from pathlib import Path
# ---------------------------------------------------------------- 常量
VAL_FIELDS = "date,code,close,peTTM,pbMRQ,psTTM,pcfNcfTTM,turn,tradestatus,isST"
VAL_NUM_COLS = ("close", "peTTM", "pbMRQ", "psTTM", "pcfNcfTTM", "turn")
DEFAULT_OUT_FUND = Path("data/fundamental")
DEFAULT_OUT_MACRO = Path("data/macro")
# ---------------------------------------------------------------- 纯函数(离线可测)
def to_bs_code(code: str) -> str:
"""任意风格 -> BaoStock:600519.SH -> sh.600519;已是 sh.600519 则不变。"""
c = str(code).strip()
if "." not in c:
return c
a, b = c.split(".", 1)
if a.lower() in ("sh", "sz", "bj"):
return f"{a.lower()}.{b}"
return f"{b.lower()}.{a}"
def to_ts_code(code: str) -> str:
"""BaoStock -> Tushare:sh.600519 -> 600519.SH。"""
c = str(code).strip()
if "." not in c:
return c
a, b = c.split(".", 1)
if a.lower() in ("sh", "sz", "bj"):
return f"{b}.{a.upper()}"
return c
def _blank_to_nan(x):
"""BaoStock 空值返回空字符串,统一转 NaN。"""
if x is None:
return float("nan")
s = str(x).strip()
if s == "" or s.lower() == "nan":
return float("nan")
return x
def parse_industry(ind: str):
"""解析证监会行业串「C15酒、饮料和精制茶制造业」-> (门类 'C', 大类 '15', 名称)。
退市/无行业的返回 ('', '', '')。
"""
s = (ind or "").strip()
if not s:
return "", "", ""
letter = s[0].upper()
if not letter.isalpha():
return "", "", s
digits = ""
i = 1
while i < len(s) and s[i].isdigit():
digits += s[i]
i += 1
return letter, digits, s[i:]
def percentile_rank(series, value) -> float:
"""value 在 series 中的分位(0~1)。空序列或 value 为 NaN 返回 NaN。
估值分位:当前 peTTM 在过去 N 年 peTTM 里的百分位,越低越便宜。
"""
import math
vals = [float(x) for x in series if x == x and not math.isnan(float(x))]
if not vals or value != value:
return float("nan")
below = sum(1 for x in vals if x <= float(value))
return below / len(vals)
def rrr_direction(pre_after: tuple) -> str:
"""准备金率前后值 -> 方向(降准 / 升准 / 不变)。"""
pre, aft = float(pre_after[0]), float(pre_after[1])
if aft < pre:
return "降准"
if aft > pre:
return "升准"
return "不变"
def macro_month_to_date(stat_year, stat_month) -> str:
"""货币供应量月度 -> 统计期字符串 'YYYY-MM'。"""
return f"{int(stat_year):04d}-{int(stat_month):02d}"
def money_avail_date(stat_year, stat_month, lag_months: int = 1) -> str:
"""货币供应量「可用日」保守推定:统计期次月首日。
货币供应量接口**只给 statYear/statMonth,没有公告日** ------ 央行一般在次月
中旬公布,保守取次月首日,宁可晚不可早(PIT 纪律)。
"""
y, m = int(stat_year), int(stat_month) + lag_months
while m > 12:
m -= 12
y += 1
return f"{y:04d}-{m:02d}-01"
def assert_has_pubdate(df, col: str = "pubDate") -> None:
"""PIT 铁律:任何要按公告日对齐的表,若无 pubDate 字段则拒绝入库。"""
if col not in df.columns:
raise ValueError(f"表缺 {col} 列,拒绝入库(PIT 铁律)")
# ---------------------------------------------------------------- 联网取数
def get_bs():
try:
import baostock as bs
except ImportError:
print("[错误] 未安装 baostock。只能 pip:", file=sys.stderr)
print(" pip install baostock -i https://mirrors.aliyun.com/pypi/simple/",
file=sys.stderr)
raise SystemExit(3)
lg = bs.login()
if lg.error_code != "0":
print(f"[错误] BaoStock 登录失败:{lg.error_code} {lg.error_msg}", file=sys.stderr)
raise SystemExit(4)
return bs
def rs_to_df(rs):
import pandas as pd
rows = []
while rs.error_code == "0" and rs.next():
rows.append(rs.get_row_data())
return pd.DataFrame(rows, columns=rs.fields)
def fetch_valuation(bs, code: str, start: str, end: str):
"""估值序列:从日线 k-data 直接取 peTTM/pbMRQ/psTTM(不复权 adjustflag=3)。"""
import pandas as pd
rs = bs.query_history_k_data_plus(
code, VAL_FIELDS, start_date=start, end_date=end,
frequency="d", adjustflag="3")
df = rs_to_df(rs)
if df is None or len(df) == 0:
return df
for c in VAL_NUM_COLS:
if c in df.columns:
df[c] = pd.to_numeric(df[c].map(_blank_to_nan), errors="coerce")
df["date"] = pd.to_datetime(df["date"])
return df.sort_values("date").reset_index(drop=True)
def fetch_industry(bs, code: str | None = None):
"""行业分类(全市场快照 / 单标的)。"""
if code:
rs = bs.query_stock_industry(code=code)
else:
rs = bs.query_stock_industry()
return rs_to_df(rs)
def fetch_macro(bs, kind: str, start: str, end: str):
"""宏观取数:deposit / loan / rrr / m2month / m2year。"""
fn = {
"deposit": bs.query_deposit_rate_data,
"loan": bs.query_loan_rate_data,
"rrr": bs.query_required_reserve_ratio_data,
"m2month": bs.query_money_supply_data_month,
"m2year": bs.query_money_supply_data_year,
}[kind]
return rs_to_df(fn(start_date=start, end_date=end))
def macro_window(kind: str, today=None) -> tuple:
"""宏观各表的取数区间(报告与落库共用,防止两处口径漂移)。
利率 / 准备金率是历史事件表,起点放 1990;货币供应量是月度流量表,
月表 2015-01 起 BaoStock 才有连续数据。**上限一律取「今天」**------
写死年份会让落库数据在跨年后静默截断(实测踩过:m2month 上限写 2025-12,
落库比接口最新月落后 8 个月,而同一份报告里却显示最新到 2026-08)。
"""
now = today or dt.date.today()
if kind == "m2month":
return "2015-01", f"{now.year}-{now.month:02d}"
if kind == "m2year":
return "2010", str(now.year)
return "1990-01-01", now.isoformat()
def split_placeholders(df, value_col: str = "m2Month"):
"""拆成(已发布行, 占位空行)。
接口用「有行但空值」表示「当月统计尚未发布」,取最后一行就会拿到 NaN。
"""
if df is None or len(df) == 0:
return df, df
blank = df[value_col].astype(str).str.strip() == ""
return df[~blank], df[blank]
# ---------------------------------------------------------------- 落库
def store_parquet(df, out_dir: Path, name: str, source: str = "baostock") -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
f = out_dir / f"{name}.parquet"
out = df.copy()
out["source"] = source
out["fetch_date"] = dt.date.today().isoformat()
out.to_parquet(f, index=False)
return f
def read_back(f: Path):
import duckdb
con = duckdb.connect()
return con.execute(f"SELECT * FROM read_parquet('{f}')").fetchdf()
# ---------------------------------------------------------------- 报告
def report_valuation(df, code: str, window_years: int = 0):
"""估值报告:最新值 + 历史分位 + 负值/停牌提醒。"""
if df is None or len(df) == 0:
print(f"[空] {code} 未取到估值序列")
return
last = df.iloc[-1]
print(f"\n[估值] {code} 样本 {len(df)} 天({df['date'].iloc[0].date()} ~ "
f"{df['date'].iloc[-1].date()})")
print(f" 最新 {last['date'].date()}: close={last['close']:.2f} "
f"peTTM={last['peTTM']:.4f} pbMRQ={last['pbMRQ']:.4f} psTTM={last['psTTM']:.4f}")
for col in ("peTTM", "pbMRQ", "psTTM"):
s = df[col]
pos = s[(s > 0) & s.notna()] # 分位只在正值上算(负 PE 无意义)
pct = percentile_rank(pos, last[col]) if last[col] and last[col] > 0 else float("nan")
print(f" {col}: 正值样本 {len(pos)} 天, 当前分位 = "
f"{'%.1f%%' % (pct * 100) if pct == pct else 'n/a(当前为负/缺失)'}")
neg = int((df["peTTM"] < 0).sum())
print(f" ⚠️ peTTM 为负的天数 = {neg}(负 PE 无意义,做估值筛选必须先过滤)")
def report_industry(bs, code: str | None):
df = fetch_industry(bs, code)
if df is None or len(df) == 0:
print("[空] 未取到行业数据")
return
if code:
r = df.iloc[0]
letter, num, name = parse_industry(r["industry"])
print(f"\n[行业] {r['code']} {r['code_name']}: {r['industry']} "
f"(门类={letter or '空'} 大类={num or '空'} 名称={name or '空'})")
print(f" 分类标准 = {r['industryClassification']} (快照日 {r['updateDate']})")
return
# 全市场统计
tot = len(df)
empty = int((df["industry"].astype(str).str.strip() == "").sum())
cls = df["industryClassification"].value_counts().to_dict()
import collections
cats = collections.Counter(
(parse_industry(i)[0] or "空") for i in df["industry"])
print(f"\n[行业] 全市场 {tot} 行,快照日 {df['updateDate'].iloc[0]}")
print(f" 分类标准分布 = {cls}")
print(f" 行业为空(多为退市股)= {empty}")
print(f" 证监会门类分布 = {dict(sorted(cats.items()))}")
def report_macro(bs):
print("\n========== 宏观数据覆盖实测 ==========")
dep = fetch_macro(bs, "deposit", *macro_window("deposit"))
loan = fetch_macro(bs, "loan", *macro_window("loan"))
rrr = fetch_macro(bs, "rrr", *macro_window("rrr"))
m2m = fetch_macro(bs, "m2month", *macro_window("m2month"))
m2y = fetch_macro(bs, "m2year", *macro_window("m2year"))
for name, d in (("存款基准利率", dep), ("贷款基准利率", loan),
("存款准备金率", rrr), ("货币供应量(月)", m2m), ("货币供应量(年)", m2y)):
if d is None or len(d) == 0:
print(f" {name}: 0 行")
continue
first = d.iloc[0].to_dict()
last = d.iloc[-1].to_dict()
key = "pubDate" if "pubDate" in d.columns else (
"statYear" if "statYear" in d.columns else d.columns[0])
print(f" {name}: {len(d)} 行,范围 {first.get(key)} ~ {last.get(key)}")
if len(rrr):
last = rrr.iloc[-1]
print(f" ⚠️ 准备金率最后一条 {last['pubDate']}"
f"(生效 {last['effectiveDate']}, 大型机构 {last['bigInstitutionsRatioPre']}"
f"->{last['bigInstitutionsRatioAfter']})------2018 年后未更新")
if len(dep):
print(f" ⚠️ 存款基准利率最后一条 {dep.iloc[-1]['pubDate']}"
f"(1年 {dep.iloc[-1]['fixedDepositRate1Year']}%)------2015-10 后冻结")
if len(m2m):
# ⚠️ 坑:接口会返回**当月占位空行**(统计数据尚未发布),直接取最后一行 = NaN。
valid, empty = split_placeholders(m2m)
if len(valid):
last = valid.iloc[-1]
ym = macro_month_to_date(last["statYear"], last["statMonth"])
print(f" M2 最新有效月 {ym}: {float(last['m2Month']):.2f} 亿元, "
f"同比 {float(last['m2YOY']):.2f}%, "
f"可用日(推定) {money_avail_date(last['statYear'], last['statMonth'])}")
if len(empty):
print(f" ⚠️ 末尾有 {len(empty)} 行「占位空行」(当月数据未发布)"
f"------直接取最后一行会拿到 NaN")
# ---------------------------------------------------------------- 用法演示:同行业比价
def pick_industry(ind, prefix: str):
"""从行业快照里挑出某行业(前缀形如 C15)的股票代码,按代码排序。"""
if ind is None or len(ind) == 0:
return []
out = []
for _, r in ind.iterrows():
name = str(r["industry"]).strip()
if name.startswith(prefix):
out.append(r["code"])
return sorted(out)
def use_demo(bs, industries, n_per: int, start: str, end: str) -> None:
"""用法演示:同一天、同一行业内部按估值分位排一遍。
回答「手上有了估值序列怎么用」里最常见的一个场景 ------ 同行业比价:
只在同一行业内部比,跨行业比 PE 没有意义。
"""
import pandas as pd
ind = fetch_industry(bs) # 行业快照只查一次,所有行业共用
if ind is None or len(ind) == 0:
print("[用法演示] 未取到行业快照")
return
for prefix in industries:
codes = pick_industry(ind, prefix)
print(f"\n[用法演示] 行业 {prefix}:成分 {len(codes)} 只,"
f"取前 {min(n_per, len(codes))} 只")
if not codes:
print(" 该行业无成分股(检查前缀)")
continue
total = min(n_per, len(codes))
print(f" 逐只取数,共 {total} 只(BaoStock 单只十几秒)...")
recs = []
for idx, c in enumerate(codes[:n_per], 1):
print(f" [{idx}/{total}] {c}", flush=True)
df = fetch_valuation(bs, c, start, end)
if df is None or len(df) == 0:
continue
last = df.iloc[-1]
recs.append({
"code": c,
"date": last["date"].date(),
"close": last["close"],
"pe": last["peTTM"],
"pb": last["pbMRQ"],
"pe_pct": percentile_rank(df["peTTM"][df["peTTM"] > 0], last["peTTM"]),
"neg_days": int((df["peTTM"] < 0).sum()),
"n": len(df),
})
if not recs:
print(" 未取到估值数据")
continue
d = pd.DataFrame(recs)
pos = d[d["pe"] > 0]
neg_n = int((d["pe"] < 0).sum())
miss_n = int(d["pe"].isna().sum())
print(f" 估值窗口 {start} ~ {end}(每只 {int(d['n'].max())} 个交易日)")
print(f" peTTM 中位数 = {pos['pe'].median():.2f}"
f"(正值 {len(pos)} 只 / 负值 {neg_n} 只 / 缺失 {miss_n} 只)")
print(f" pbMRQ 中位数 = {d['pb'].median():.2f}")
if len(pos) < 3:
print(" 正值样本不足 3 只,不排分位(样本太少别比)")
continue
print(" 估值分位最低 3 只:")
for _, r in pos.nsmallest(3, "pe_pct").iterrows():
print(f" {r['code']} close={r['close']:.2f} peTTM={r['pe']:.2f}"
f" 分位 {r['pe_pct'] * 100:.1f}%")
print(" 估值分位最高 3 只:")
for _, r in pos.nlargest(3, "pe_pct").iterrows():
print(f" {r['code']} close={r['close']:.2f} peTTM={r['pe']:.2f}"
f" 分位 {r['pe_pct'] * 100:.1f}%")
# ---------------------------------------------------------------- 自检(离线)
def self_test() -> int:
import pandas as pd
ok, fails = 0, []
def check(name, cond):
nonlocal ok
if cond:
ok += 1
print(f" [PASS] {name}")
else:
fails.append(name)
print(f" [FAIL] {name}")
print("=" * 80)
print(" s3_009_fundamental 自检(离线,不联网)")
print("=" * 80)
# 1) 代码风格互转
check("to_bs_code: 600519.SH -> sh.600519", to_bs_code("600519.SH") == "sh.600519")
check("to_bs_code 幂等", to_bs_code("sh.600519") == "sh.600519")
check("to_ts_code: sh.600519 -> 600519.SH", to_ts_code("sh.600519") == "600519.SH")
# 2) 空值
check("空串 -> NaN", _blank_to_nan("") != _blank_to_nan(""))
check("None -> NaN", _blank_to_nan(None) != _blank_to_nan(None))
# 3) 行业解析(真实样本)
check("行业=茅台 -> 门类 C/大类 15/名称",
parse_industry("C15酒、饮料和精制茶制造业") == ("C", "15", "酒、饮料和精制茶制造业"))
check("行业=空 -> ('','','')", parse_industry("") == ("", "", ""))
check("行业=浦发 -> 门类 J/大类 66",
parse_industry("J66货币金融服务")[:2] == ("J", "66"))
# 4) 分位
check("分位: 中间值 = 0.5", abs(percentile_rank([1, 2, 3, 4, 5], 3) - 0.6) < 1e-12)
check("分位: 最大值 = 1.0", percentile_rank([1, 2, 3], 3) == 1.0)
check("分位: 空序列 = NaN", percentile_rank([], 1) != percentile_rank([], 1))
# 5) 降准方向
check("降准判定 (16.0->15.5)", rrr_direction((16.0, 15.5)) == "降准")
check("升准判定 (6.0->7.0)", rrr_direction((6.0, 7.0)) == "升准")
check("不变判定", rrr_direction((7.0, 7.0)) == "不变")
# 6) 宏观日期
check("月度 -> YYYY-MM", macro_month_to_date("2024", "01") == "2024-01")
check("可用日保守推定 = 次月首日", money_avail_date("2024", "01") == "2024-02-01")
check("可用日跨年 = 次年首月", money_avail_date("2024", "12") == "2025-01-01")
# 6b) 取数区间上限必须是「今天」------写死年份会让落库静默落后
check("区间上限=今天 (m2month)",
macro_window("m2month", dt.date(2027, 3, 15)) == ("2015-01", "2027-03"))
check("区间上限=今天 (rrr)",
macro_window("rrr", dt.date(2027, 3, 15)) == ("1990-01-01", "2027-03-15"))
ph_ok, ph_empty = split_placeholders(pd.DataFrame({"m2Month": ["100", "", "200"]}))
check("占位空行拆分(有效 2 / 空 1)", len(ph_ok) == 2 and len(ph_empty) == 1)
# 7) PIT 铁律
empty = pd.DataFrame([{"statMonth": "01"}])
raised = False
try:
assert_has_pubdate(empty)
except ValueError:
raised = True
check("无 pubDate 的宏观表被拒绝(PIT 铁律)", raised)
# 8) 估值负值必须能被识别(真实样本 sh.600606 = -7.847860)
val = pd.DataFrame({"peTTM": [30.37, -7.85, 20.0]})
check("负 PE 能被识别", int((val["peTTM"] < 0).sum()) == 1)
print("-" * 80)
if fails:
print(f"自检结果:{ok} 通过 / {len(fails)} 失败 -> {fails}")
return 1
print(f"自检结果:{ok}/{ok} 全部通过 ✅(离线,未联网)")
return 0
# ---------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser(description="S3-009 基本面:估值/行业/宏观(BaoStock)")
ap.add_argument("--self-test", action="store_true", help="离线自检,不联网")
ap.add_argument("--real", action="store_true", help="真实取数报告(联网)")
ap.add_argument("--store", action="store_true", help="落库(联网)")
ap.add_argument("--use", action="store_true", help="用法演示:同行业估值比价(联网)")
ap.add_argument("--industries", default="C15,J66",
help="行业前缀,逗号分隔,如 C15,J66(证监会门类+大类)")
ap.add_argument("--n-per", type=int, default=12, help="每个行业取前 N 只")
ap.add_argument("--code", default="sh.600519", help="BaoStock 代码,如 sh.600519")
ap.add_argument("--start", default="2019-01-01", help="估值起点")
ap.add_argument("--end", default=dt.date.today().isoformat(), help="估值终点")
args = ap.parse_args()
if args.self_test:
return self_test()
if not (args.real or args.store or args.use):
ap.print_help()
return 2
bs = get_bs()
try:
if args.real:
val = fetch_valuation(bs, args.code, args.start, args.end)
report_valuation(val, args.code)
report_industry(bs, args.code)
report_macro(bs)
if args.use:
use_demo(bs, [s.strip() for s in args.industries.split(",") if s.strip()],
args.n_per, args.start, args.end)
if args.store:
val = fetch_valuation(bs, args.code, args.start, args.end)
if val is not None and len(val):
f = store_parquet(val, DEFAULT_OUT_FUND / "valuation", to_ts_code(args.code))
back = read_back(f)
print(f"[完成] valuation {len(val)} 行 -> {f} (读回 {len(back)} 行)")
ind = fetch_industry(bs)
if ind is not None and len(ind):
f = store_parquet(ind, DEFAULT_OUT_FUND / "industry", "industry_snapshot")
print(f"[完成] industry {len(ind)} 行 -> {f}")
for kind in ("m2month", "rrr", "deposit", "loan"):
d = fetch_macro(bs, kind, *macro_window(kind))
if d is not None and len(d):
f = store_parquet(d, DEFAULT_OUT_MACRO, kind)
note = ""
if kind == "m2month":
_, empty = split_placeholders(d)
if len(empty):
note = (f"(含 {len(empty)} 行占位空行,未发布月;"
f"查询时按 m2Month 非空过滤)")
print(f"[完成] macro/{kind} {len(d)} 行 -> {f}{note}")
return 0
finally:
try:
bs.logout()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main())
免责声明
本文仅为量化数据工程的技术分享,所有代码与示例数据均用于演示,不构成任何投资建议 。实盘有风险,下单需谨慎;文中涉及的接口字段、估值分位、行业快照日、宏观数据覆盖区间等均来自 2026-10-01 本机实测(conda ashare / baostock 0.9.3),会随数据源版本、市场变化与数据披露节奏变化,需根据你自己的环境重新校准。投资盈亏自负。