314 lines
16 KiB
Python
314 lines
16 KiB
Python
# 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_{t−4季}(要求恰好隔 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_{t−4季})(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_{y−5})^{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+DIO−DPO(缺任一原料 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
|