Files
sanguo_vnpy_v2/sanguo_factor/fundamental_report_features.py
T
2026-09-10 07:18:28 +08:00

314 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# sanguo_factor/fundamental_report_features.py
"""报告期级指标预计算: 报告期宽表 → 全部报告期级特征(单季化/TTM/比率族)
(自 fundamental_adapter.py 拆出,纯结构重构).
R1/R2 口径红线与逐级 with_columns 的装配顺序见 fundamental_adapter.py
模块 docstring;所有 shift/rolling 必须在 .over(_SYM) 组内执行.
"""
from __future__ import annotations
import polars as pl
from .fundamental_schema import _CUM_MAP, _DA_PARTS, _FIN_NULL_COLS, _IBD_PARTS, _SYM
# ==================== 报告期级指标预计算 ====================
# 所有 shift/rolling 必须在 .over(_SYM) 组内执行(跨股串行 = 致命错误)。
def _qidx() -> pl.Expr:
"""连续季度索引: year*4 + quarter(月 3/6/9/12 → 1/2/3/4)."""
return (pl.col("REPORT_DATE").dt.year() * 4
+ (pl.col("REPORT_DATE").dt.month() - 1) // 3 + 1)
def _single_quarter(col: str) -> pl.Expr:
"""R1 单季化: Q1 直接取累计;其余要求上期恰为上一季度(同年)做差,否则 NaN."""
cur = pl.col(col)
prev = cur.shift(1)
prev_ok = (pl.col("_qidx") - pl.col("_qidx").shift(1)) == 1
return (
pl.when(cur.is_null()).then(None)
.when(pl.col("REPORT_DATE").dt.month() == 3).then(cur)
.when(prev_ok & prev.is_not_null()).then(cur - prev)
.otherwise(None)
).over(_SYM)
def _ttm_of(col: str) -> pl.Expr:
"""R2 TTM = 连续 4 个报告期单季之和(窗内任一单季 NaN → NaN)."""
q = pl.col(col)
window_ok = (pl.col("_qidx") - pl.col("_qidx").shift(3)) == 3
s = q + q.shift(1) + q.shift(2) + q.shift(3)
return pl.when(window_ok).then(s).otherwise(None).over(_SYM)
def _yoy4(col: str) -> pl.Expr:
"""yoy = X_t / X_{t−4季} − 1;基期缺失/≤0 → NaN(负基数 yoy 无意义)."""
cur, base = pl.col(col), pl.col(col).shift(4)
ok = (pl.col("_qidx") - pl.col("_qidx").shift(4)) == 4
return (
pl.when(ok & base.is_not_null() & (base > 0) & cur.is_not_null())
.then(cur / base - 1.0).otherwise(None)
).over(_SYM)
def _delta4(col: str) -> pl.Expr:
"""ΔX = X_t X_{t4季}(要求恰好隔 4 个季度)."""
ok = (pl.col("_qidx") - pl.col("_qidx").shift(4)) == 4
return pl.when(ok).then(pl.col(col) - pl.col(col).shift(4)).otherwise(None).over(_SYM)
def _mean4(col: str) -> pl.Expr:
"""mean(X_t, X_{t4季})(A03 平均 ROE 分母;恰隔 4 季 + 两期非空守卫)."""
ok = (pl.col("_qidx") - pl.col("_qidx").shift(4)) == 4
both = pl.col(col).is_not_null() & pl.col(col).shift(4).is_not_null()
m = (pl.col(col) + pl.col(col).shift(4)) / 2.0
return pl.when(ok & both).then(m).otherwise(None).over(_SYM)
def _cagr5(col: str) -> pl.Expr:
"""5 年 CAGR = (X_y / X_{y5})^{1/5} − 1(年报行;基期/现期 ≤0 → NaN,
负基期 CAGR 无意义;恰隔 20 季守卫)."""
cur, base = pl.col(col), pl.col(col).shift(20)
ok = (pl.col("_qidx") - pl.col("_qidx").shift(20)) == 20
valid = ok & base.is_not_null() & (base > 0) & cur.is_not_null() & (cur > 0)
return pl.when(valid).then((cur / base).pow(0.2) - 1.0).otherwise(None).over(_SYM)
def _std16(col: str) -> pl.Expr:
"""16 季滚动 sample std(ddof=1,与 SUE Foster 同款钉死);窗口须恰为连续
16 个季度且全非空——不足 16 期/窗内含缺失 → NaN(不填 0)."""
ok = ((pl.col("_qidx") - pl.col("_qidx").shift(15)) == 15).over(_SYM)
sd = pl.col(col).rolling_std(window_size=16, ddof=1).over(_SYM)
return pl.when(ok & sd.is_not_null()).then(sd).otherwise(None)
def _safe_ratio(num: pl.Expr, den: pl.Expr) -> pl.Expr:
"""分母缺失/为 0 → NaN(比率类通用守卫)."""
return pl.when(den.is_not_null() & (den != 0)).then(num / den).otherwise(None)
def _compute_report_features(reports: pl.DataFrame) -> pl.DataFrame:
"""报告期宽表 → 全部报告期级特征(逐级 with_columns,over 组内时序)."""
df = reports.sort([_SYM, "REPORT_DATE"]).with_columns(_qidx().alias("_qidx"))
# 第一级: 单季化 + TTM(累计列)
df = df.with_columns(
[_single_quarter(c).alias(f"q_{c}") for c in _CUM_MAP.values()]
).with_columns(
[_ttm_of(f"q_{c}").alias(f"ttm_{c}") for c in _CUM_MAP.values()]
)
# 第一级半(P1): 补充资料加工底座
# - DA 累计 = 四件折旧摊销之和(缺列/缺值按 0,与 IBD 组装同款;
# USERIGHT_ASSET_AMORTIZE 2019 前整列缺失不影响早年 DA)
# - 利息费用累计 fill 0(NAS 实测披露稀疏: 600519 78% null/银行模板整列缺;
# 未披露按 0 回加 → EBIT 退化为 TP,口径登记)
# - NWC = 存货+应收(null 传播,缺一即 NaN)
# - 年报行门控列(C13/C14/C18 年度口径;非年报行 null → yoy 天然 NaN)
df = df.with_columns(
pl.sum_horizontal([pl.col(p).fill_null(0.0) for p in _DA_PARTS]).alias("_da_cum"),
pl.col("int_exp").fill_null(0.0).alias("_int0_cum"),
(pl.col("INVENTORY") + pl.col("ACCOUNTS_RECE")).alias("_nwc"),
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("rev")).alias("_rev_ann"),
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("np")).alias("_np_ann"),
pl.when(pl.col("REPORT_DATE").dt.month() == 12).then(pl.col("capex")).alias("_capex_ann"),
).with_columns(
_single_quarter("_da_cum").alias("q__da"),
_single_quarter("_int0_cum").alias("q__int0"),
).with_columns(
_ttm_of("q__da").alias("ttm__da"),
_ttm_of("q__int0").alias("ttm__int0"),
)
# 第二级: IBD(缺组件按 0,钉死含 LEASE_LIAB 版) + 金融股判定(银行模板无
# 营业成本) + 毛利 + τ 实际税率(TTM 口径: 消费者均为 TTM 流量;
# τ = INCOME_TAX_TTM/TOTAL_PROFIT_TTM 截断 [0,0.5],两列缺失或 TP≤0 → 0.25)
ibd = pl.sum_horizontal([pl.col(p).fill_null(0.0) for p in _IBD_PARTS])
is_fin = pl.col("cogs").is_null() | (pl.col("cogs") == 0)
tau = (
pl.when(pl.col("ttm_tp").is_not_null() & (pl.col("ttm_tp") > 0)
& pl.col("ttm_tax").is_not_null())
.then((pl.col("ttm_tax") / pl.col("ttm_tp")).clip(0.0, 0.5))
.otherwise(0.25)
)
df = df.with_columns(
ibd.alias("_ibd"),
is_fin.alias("_is_fin"),
tau.alias("_tau"),
(pl.col("ttm_rev") - pl.col("ttm_cogs")).alias("_gp_ttm"),
# EBIT_TTM = (TOTAL_PROFIT + FE_INTEREST_EXPENSE)_TTM(§1.3)
(pl.col("ttm_tp") + pl.col("ttm__int0")).alias("_ebit_ttm"),
# (EBIT+DA)_TTM / FCF_TTM / 债务净发行 TTM(E03 三流合成)
(pl.col("ttm_tp") + pl.col("ttm__int0") + pl.col("ttm__da")).alias("_ebitda_ttm"),
(pl.col("ttm_cfo") - pl.col("ttm_capex")).alias("_fcf_ttm"),
(pl.col("ttm_recv_loan") + pl.col("ttm_issue_bond")
- pl.col("ttm_pay_debt")).alias("_debt_issue_ttm"),
)
# 第二级半(P1): 平均净资产(A03 分母) + VSIG 三序列底座(单季口径 / TA)
ta = pl.col("TOTAL_ASSETS")
df = df.with_columns(
_mean4("TOTAL_PARENT_EQUITY").alias("_eq_avg"),
_safe_ratio(pl.col("q_np"), ta).alias("_np_ta"),
_safe_ratio(pl.col("q_np") - pl.col("q_cfo"), ta).alias("_accq_ta"),
_safe_ratio(pl.col("q_cfo"), ta).alias("_cfo_ta"),
# P1-B: E08 CCC 三分子(「均值」= t 与 t−4 报告期期末余额平均)
_mean4("ACCOUNTS_RECE").alias("_ar_avg"),
_mean4("INVENTORY").alias("_inv_avg"),
_mean4("ACCOUNTS_PAYABLE").alias("_ap_avg"),
)
# 第三级: 行本地比率(无时序,无需 over)
eq = pl.col("TOTAL_PARENT_EQUITY")
df = df.with_columns(
# 盈利能力 A
_safe_ratio(pl.col("ttm_np"), eq).alias("roe_ttm"),
_safe_ratio(pl.col("ttm_dnp"), eq).alias("roe_deduct_ttm"),
_safe_ratio(pl.col("ttm_np"), ta).alias("roa_ttm"),
_safe_ratio(pl.col("_gp_ttm"), ta).alias("gp_over_assets"),
_safe_ratio(pl.col("_gp_ttm"), pl.col("ttm_rev")).alias("gross_margin"),
_safe_ratio(pl.col("ttm_np"), pl.col("ttm_rev")).alias("net_margin"),
_safe_ratio(pl.col("ttm_cfo"), ta).alias("cfo_over_assets"),
# 盈利质量 B
_safe_ratio(pl.col("ttm_np") - pl.col("ttm_cfo"), ta).alias("tacc"),
_safe_ratio(pl.col("ttm_np") - pl.col("ttm_dnp"),
pl.col("ttm_np").abs()).alias("nonrec_ratio"),
_safe_ratio((pl.col("ttm_asset_imp") + pl.col("ttm_credit_imp")).abs(),
ta).alias("impairment_ratio"),
_safe_ratio(pl.col("ttm_invest_inc") + pl.col("ttm_fv_inc"),
pl.col("ttm_tp").abs()).alias("invest_income_dep"),
_safe_ratio(pl.col("ttm_sales_cash"), pl.col("ttm_rev")).alias("sales_cash_ratio"),
_safe_ratio(pl.col("OTHER_RECE"), ta).alias("other_rece_ratio"),
# 成长 C / 资本结构 E
_safe_ratio(pl.col("_ibd"), ta).alias("ibd_ratio"),
_safe_ratio(pl.col("GOODWILL"), ta).alias("goodwill_ratio"),
# 盈利能力 A(P1 7): A03/A09/A10/A11/A12/A15/A16
_safe_ratio(pl.col("ttm_np"), pl.col("_eq_avg")).alias("roe_avg"),
_safe_ratio(pl.col("ttm_np") + pl.col("ttm__int0") * (1.0 - pl.col("_tau")),
ta).alias("roa_pretax"),
_safe_ratio(pl.col("_ebit_ttm"), ta).alias("ebit_over_assets"),
_safe_ratio(pl.col("_ebitda_ttm"), pl.col("ttm_rev")).alias("ebitda_margin"),
_safe_ratio(pl.col("_ebit_ttm") * (1.0 - pl.col("_tau")),
eq + pl.col("_ibd") - pl.col("MONETARYFUNDS")).alias("roic"),
_safe_ratio(pl.col("ttm_research"), pl.col("ttm_rev")).alias("rd_intensity"),
_safe_ratio(pl.col("ttm_sale_exp"), pl.col("ttm_rev")).alias("sale_expense_ratio"),
# 盈利质量 B(P1): B12 哑变量的连续近似 = (MON/TA)×(IBD/TA) 乘积变体
# (表达式引擎无截面分位函数,不改引擎——survey B12 的可计算降级)
(_safe_ratio(pl.col("MONETARYFUNDS"), ta)
* _safe_ratio(pl.col("_ibd"), ta)).alias("cash_ibd_product"),
_safe_ratio(pl.col("ttm__da"), pl.col("ttm_rev")).alias("da_intensity"),
# E07 利息保障倍数(利息费用≤0 → NaN: 负利息=净收入,倍数无意义)
pl.when(pl.col("ttm__int0") > 0)
.then(pl.col("_ebit_ttm") / pl.col("ttm__int0"))
.otherwise(None).alias("interest_cover"),
# P1-B E08 现金转换周期(三表自算,不用 abstract 域):
# DSO=365×AR均值/REV_TTM;DIO=365×INV均值/COGS_TTM;
# DPO=365×AP均值/COGS_TTM;CCC=DSO+DIODPO(缺任一原料 NaN 传播)
(_safe_ratio(pl.col("_ar_avg") * 365.0, pl.col("ttm_rev"))
+ _safe_ratio(pl.col("_inv_avg") * 365.0, pl.col("ttm_cogs"))
- _safe_ratio(pl.col("_ap_avg") * 365.0, pl.col("ttm_cogs"))).alias("ccc"),
)
# 第四级: 跨期差分/同比/剪刀差(over 组内时序)
df = df.with_columns(
_yoy4("q_rev").alias("rev_q_yoy"),
_yoy4("q_np").alias("np_q_yoy"),
_yoy4("ACCOUNTS_RECE").alias("_ar_yoy"),
_yoy4("ttm_rev").alias("_rev_ttm_yoy"),
_yoy4("SHARE_CAPITAL").alias("nsi"),
_yoy4("TOTAL_ASSETS").alias("asset_growth"),
_delta4("gross_margin").alias("gm_delta"),
_delta4("roe_ttm").alias("roe_delta"),
_delta4("q_np").alias("_diff4_np"),
_delta4("q_rev").alias("_diff4_rev"),
# P1: B09 存货同比 / C16 NWC 同比 / C19 净资产 / E10 商誉
_yoy4("INVENTORY").alias("_inv_yoy"),
_yoy4("_nwc").alias("nwc_growth"),
_yoy4("TOTAL_PARENT_EQUITY").alias("equity_growth"),
_yoy4("GOODWILL").alias("goodwill_growth"),
# P1: C18 投资增速(年度口径,非年报行 cur=null → NaN)
_yoy4("_capex_ann").alias("invest_growth"),
# P1: C13/C14 五年 CAGR(年报行,恰隔 20 季守卫,基期/现期≤0 → NaN)
_cagr5("_rev_ann").alias("rev_cagr5"),
_cagr5("_np_ann").alias("np_cagr5"),
_delta4("net_margin").alias("nm_delta"),
_delta4("q_eps").alias("_diff4_eps"),
).with_columns(
(pl.col("np_q_yoy") - pl.col("rev_q_yoy")).alias("growth_scissors"),
(pl.col("_ar_yoy") - pl.col("_rev_ttm_yoy")).alias("receivables_anomaly"),
# B09 与 B08 同构: 期末存量同比 − REV_TTM 同比(登记口径)
(pl.col("_inv_yoy") - pl.col("_rev_ttm_yoy")).alias("inventory_anomaly"),
# B18 毛净剪刀差 = GM_TTM NM_TTM(第三级产物,同块不可引用故后置)
(pl.col("gross_margin") - pl.col("net_margin")).alias("gm_nm_scissors"),
# C07/C08 加速度 = yoy 的恰隔 4 季二次差分(基期>0 守卫由 yoy 层继承,
# 二次差分同样 NaN 传播;同块不可引用 yoy 列故后置)
_delta4("np_q_yoy").alias("np_accel"),
_delta4("rev_q_yoy").alias("rev_accel"),
)
# 第五级: SUE(Foster 标准化)= diff4 / std(过去 8 期 diff4, ddof=1)
for src, out in (("_diff4_np", "sue_np"), ("_diff4_rev", "sue_rev"),
("_diff4_eps", "sue_eps")):
sd = pl.col(src).rolling_std(window_size=8, ddof=1).over(_SYM)
df = df.with_columns(
pl.when(sd.is_not_null() & (sd > 0) & pl.col(src).is_not_null())
.then(pl.col(src) / sd).otherwise(None).alias(out)
)
# SUE 严窗变体(随批互评): σ 只用 t−1 及更早差分(shift(1) 后滚 8 期,不含当期)
sd_strict = pl.col("_diff4_np").shift(1).rolling_std(window_size=8, ddof=1).over(_SYM)
df = df.with_columns(
pl.when(sd_strict.is_not_null() & (sd_strict > 0) & pl.col("_diff4_np").is_not_null())
.then(pl.col("_diff4_np") / sd_strict).otherwise(None).alias("sue_np_strict")
)
# 第五级半(P1): VSIG 16 季滚动(sample std ddof=1 钉死,恰连续 16 季全非空)
# + F06 披露及时性 = −(有效披露日 − 报告期) 天数(早披露=高分;notice_eff
# 取三表最晚可见,与 PIT 锚一致)
df = df.with_columns(
_std16("_np_ta").alias("vsig"),
_std16("_accq_ta").alias("vsig_acc"),
_std16("_cfo_ta").alias("vsig_cfo"),
(-(pl.col("notice_eff") - pl.col("REPORT_DATE")).dt.total_days())
.cast(pl.Float64).alias("disclosure_speed"),
)
# 第五级半续(P1): B19 持续盈利季数 = 连续单季 NP>0 计数(截断 8;
# 当期缺失→NaN;非正→0 断流;中间缺失行视为断流点)
df = df.with_columns(
(pl.col("q_np") > 0).alias("_pos"),
pl.int_range(pl.len()).cast(pl.Int64).alias("_ridx"),
).with_columns(
pl.when(pl.col("_pos").is_null() | ~pl.col("_pos"))
.then(pl.col("_ridx")).otherwise(None)
.fill_null(strategy="forward").over(_SYM).alias("_lastbrk"),
).with_columns(
pl.when(pl.col("_pos").is_null()).then(None)
.when(pl.col("_pos"))
.then((pl.col("_ridx") - pl.col("_lastbrk").fill_null(-1)).clip(1, 8).cast(pl.Float64))
.otherwise(0.0).alias("profit_streak"),
)
# 金融股: 盈利质量/成长/费用类族特征置 NaN(§7 红线 5;估值族保留)
df = df.with_columns([
pl.when(pl.col("_is_fin")).then(None).otherwise(pl.col(c)).alias(c)
for c in _FIN_NULL_COLS
])
# 输出别名(估值/资本行为因子的日频原料)
df = df.with_columns(
pl.col("ttm_np").alias("np_ttm"),
pl.col("ttm_dnp").alias("dnp_ttm"),
pl.col("ttm_cfo").alias("cfo_ttm"),
pl.col("ttm_acc_inv_cash").alias("acc_invest_cash_ttm"),
pl.col("TOTAL_PARENT_EQUITY").alias("equity"),
pl.col("SHARE_CAPITAL").alias("share_capital"),
# P1: EV 群/FCF/债务净发行原料(EV = close×share_capital + ev_ex_mv)
pl.col("_gp_ttm").alias("gp_ttm"),
pl.col("_ebit_ttm").alias("ebit_ttm"),
pl.col("_ebitda_ttm").alias("ebitda_ttm"),
pl.col("_fcf_ttm").alias("fcf_ttm"),
(pl.col("_ibd") - pl.col("MONETARYFUNDS")).alias("ev_ex_mv"),
pl.col("_debt_issue_ttm").alias("debt_issue_ttm"),
)
return df