从零搭建你的 A 股量化系统(三十五):基本面:估值 / 行业 / 宏观(BaoStock)

系列名:《从零搭建你的 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 给的是证监会分类,而且是"当前快照"

行业分类不是拿来填表的,它解决三件事:

  1. 让估值可比------不同行业的估值中枢差着量级,跨行业比 PE 得出的结论是假的(第六节有实测数字);
  2. 约束组合别押在一个行业------按行业分组统计持仓,才看得出自己是不是「伪分散」:买了 10 只,8 只同属一个门类;
  3. 看整个行业是不是一起变贵------同行业个股估值同步抬升,反映的往往是行业景气,而不是个股便宜。

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 一个源取齐了:

  1. 估值 :peTTM / pbMRQ / psTTM 就挂在日线接口上,且与复权无关------茅台当前三个指标同时落在近 7 年极低分位;
  2. 行业 :query_stock_industry 给的是证监会行业分类 (不是申万)、且是当前快照,拿它跑历史就是前视偏差;
  3. 宏观:三个接口各有各的覆盖边界,M2 还有当月占位空行;宏观三类的可用日规则也不一样;
  4. 用法 :排雷(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 本机实测(conda ashare / 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),会随数据源版本、市场变化与数据披露节奏变化,需根据你自己的环境重新校准。投资盈亏自负。

相关推荐
benchmark_cc8 小时前
批量行情返回后,怎样把请求失败的股票和正常数据分开?
python·数据分析·pandas·量化交易·股票数据·quantdash
quantdash_cc2 天前
Python 获取实时行情后如何进行批量筛选?从全市场快照到策略候选池
开发语言·python·数据分析·量化交易·股票数据·quantdash
2601_966949653 天前
每日自动更新股票行情:如何设计可靠的数据任务,避免重复写入和脏数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
2601_966949654 天前
股票池发生变化后,如何高效更新行情数据?从全量刷新到增量同步
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
2601_966949654 天前
量化回测中分钟数据应该如何保存?从文件到数据仓库的工程化设计
开发语言·python·pandas·量化交易·股票数据·quantdash
benchmark_cc4 天前
如何设计一个 Python 实时行情获取程序?从轮询、数据处理到量化策略接入
python·数据分析·pandas·量化交易·股票数据·quantdash
quantdash_cc12 天前
量化策略为什么需要实时行情数据?从信号产生到交易决策的时间差说起
开发语言·python·数据分析·量化交易·股票数据·quantdash
benchmark_cc13 天前
策略临时需要一批股票的最新价格,先查行情还是先建股票池?——从量化策略执行逻辑看数据获取顺序
python·数据分析·pandas·量化交易·股票数据·quantdash
2601_9669496514 天前
只拿到股票代码还不够:量化程序到底还需要哪些标的信息?——从数据建模开始理解股票元数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash