# 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